From 9adae590b3c15cf697a6f8e793d61ad401330bdb Mon Sep 17 00:00:00 2001 From: Ben Lesh Date: Sun, 7 Feb 2021 17:29:59 -0600 Subject: [PATCH] feat(ajax): Add option for streaming progress - Fixes a bunch of tests that were slightly off or had bad assumptions - Adds two new features: `includeUploadProgress` and `includeDownloadProgress` that will add additional events to the output stream before the event that is usually emitted. Resolved #2833 --- spec/observables/dom/ajax-spec.ts | 574 ++++++++++++++++++++++++------ src/internal/ajax/AjaxResponse.ts | 17 +- src/internal/ajax/ajax.ts | 44 ++- src/internal/ajax/types.ts | 18 + 4 files changed, 528 insertions(+), 125 deletions(-) diff --git a/spec/observables/dom/ajax-spec.ts b/spec/observables/dom/ajax-spec.ts index a8345ee98d2..8eba1aa8a68 100644 --- a/spec/observables/dom/ajax-spec.ts +++ b/spec/observables/dom/ajax-spec.ts @@ -6,12 +6,10 @@ import { TestScheduler } from 'rxjs/testing'; import { noop } from 'rxjs'; import * as nodeFormData from 'form-data'; - - const root: any = (typeof globalThis !== 'undefined' && globalThis) || (typeof self !== 'undefined' && self) || global; if (typeof root.FormData === 'undefined') { - root.FormData = nodeFormData as any; + root.FormData = nodeFormData as any; } /** @test {ajax} */ @@ -121,7 +119,7 @@ describe('ajax', () => { }); afterEach(() => { - delete (global as any).document; + delete (global as any).document; }); it('should send the cookie with a custom header to the same domain', () => { @@ -224,23 +222,22 @@ describe('ajax', () => { it('should error if createXHR throws', () => { let error; - const obj = { + + ajax({ url: '/flibbertyJibbet', responseType: 'text', createXHR: () => { throw new Error('wokka wokka'); }, - }; - - ajax(obj).subscribe( + }).subscribe( () => { - throw 'should not next'; + throw new Error('should not next'); }, (err: any) => { error = err; }, () => { - throw 'should not complete'; + throw new Error('should not complete'); } ); @@ -282,7 +279,6 @@ describe('ajax', () => { ajax({ url: '/flibbertyJibbet', - responseType: 'text', method: '', }).subscribe( (x: any) => { @@ -298,16 +294,16 @@ describe('ajax', () => { MockXMLHttpRequest.mostRecent.respondWith({ status: 200, - contentType: 'application/json', + responseType: 'json', responseText: JSON.stringify(expected), }); expect(result!.xhr).exist; - expect(result!.response).to.deep.equal(JSON.stringify({ foo: 'bar' })); + expect(result!.response).to.deep.equal({ foo: 'bar' }); expect(complete).to.be.true; }); - it('should fail if fails to parse response', () => { + it('should fail if fails to parse response in older IE', () => { let error: any; const obj: AjaxConfig = { url: '/flibbertyJibbet', @@ -315,22 +311,24 @@ describe('ajax', () => { method: '', }; + // No `response` property on the object (for older IE). + MockXMLHttpRequest.noResponseProp = true; + ajax(obj).subscribe( () => { - throw 'should not next'; + throw new Error('should not next'); }, (err: any) => { error = err; }, () => { - throw 'should not complete'; + throw new Error('should not complete'); } ); MockXMLHttpRequest.mostRecent.respondWith({ status: 207, - contentType: '', - responseType: '', + responseType: 'json', responseText: 'Wee! I am text, but should be valid JSON!', }); @@ -348,13 +346,13 @@ describe('ajax', () => { ajax(obj).subscribe( () => { - throw 'should not next'; + throw new Error('should not next'); }, (err: any) => { error = err; }, () => { - throw 'should not complete'; + throw new Error('should not complete'); } ); @@ -362,7 +360,7 @@ describe('ajax', () => { MockXMLHttpRequest.mostRecent.respondWith({ status: 404, - contentType: 'text/plain', + responseType: 'text', responseText: 'Wee! I am text!', }); @@ -395,7 +393,7 @@ describe('ajax', () => { MockXMLHttpRequest.mostRecent.respondWith({ status: 300, - contentType: 'text/plain', + responseType: 'text', responseText: 'Wee! I am text!', }); @@ -414,20 +412,19 @@ describe('ajax', () => { ajax(obj).subscribe( () => { - throw 'should not next'; + throw new Error('should not next'); }, (err: any) => { error = err; }, () => { - throw 'should not complete'; + throw new Error('should not complete'); } ); MockXMLHttpRequest.mostRecent.respondWith({ status: 404, - contentType: '', - responseType: '', + responseType: 'text', responseText: 'This is not what we expected is it? But that is okay', }); @@ -452,7 +449,7 @@ describe('ajax', () => { expect(MockXMLHttpRequest.mostRecent.url).to.equal('/flibbertyJibbet'); MockXMLHttpRequest.mostRecent.respondWith({ status: 200, - contentType: 'text/plain', + responseType: 'text', responseText: expected, }); }); @@ -477,7 +474,7 @@ describe('ajax', () => { expect(MockXMLHttpRequest.mostRecent.url).to.equal('/flibbertyJibbet'); MockXMLHttpRequest.mostRecent.respondWith({ status: 500, - contentType: 'text/plain', + responseType: 'text', responseText: expected, }); }); @@ -508,7 +505,7 @@ describe('ajax', () => { request.respondWith({ status: 200, - contentType: 'text/plain', + responseType: 'text', responseText: 'Wee! I am text!', }); }); @@ -542,7 +539,7 @@ describe('ajax', () => { rxTestScheduler.schedule(() => { request.respondWith({ status: 200, - contentType: 'text/plain', + responseType: 'text', responseText: 'Wee! I am text!', }); }, 1000); @@ -558,26 +555,17 @@ describe('ajax', () => { async: false, }; - ajax(obj).subscribe( - (x: any) => { - expect(x.status).to.equal(200); - expect(x.xhr.method).to.equal('GET'); - expect(x.xhr.async).to.equal(false); - expect(x.xhr.timeout).to.be.undefined; - expect(x.xhr.responseType).to.equal(''); - }, - () => { - throw 'should not have been called'; - } - ); + ajax(obj).subscribe(); - const request = MockXMLHttpRequest.mostRecent; + const mockXHR = MockXMLHttpRequest.mostRecent; - expect(request.url).to.equal('/flibbertyJibbet'); + expect(mockXHR.url).to.equal('/flibbertyJibbet'); + // Open was called with async `false`. + expect(mockXHR.async).to.be.false; - request.respondWith({ + mockXHR.respondWith({ status: 200, - contentType: 'text/plain', + responseType: 'text', responseText: 'Wee! I am text!', }); }); @@ -719,7 +707,7 @@ describe('ajax', () => { request.respondWith({ status: 200, - contentType: 'application/json', + responseType: 'json', responseText: JSON.stringify(expected), }); @@ -728,7 +716,6 @@ describe('ajax', () => { }); it('should succeed on 204 No Content', () => { - const expected: null = null; let result; let complete = false; @@ -748,11 +735,11 @@ describe('ajax', () => { request.respondWith({ status: 204, - contentType: 'application/json', - responseText: expected, + responseType: '', + responseText: '', }); - expect(result).to.deep.equal(expected); + expect(result).to.deep.equal(undefined); expect(complete).to.be.true; }); @@ -777,7 +764,7 @@ describe('ajax', () => { request.respondWith({ status: 200, - contentType: 'application/json', + responseType: 'json', responseText: JSON.stringify(expected), }); @@ -813,7 +800,7 @@ describe('ajax', () => { request.respondWith({ status: 200, - contentType: 'application/json', + responseType: 'json', responseText: JSON.stringify(expected), }); @@ -823,11 +810,10 @@ describe('ajax', () => { }); it('should succeed on 204 No Content', () => { - const expected: null = null; let result: AjaxResponse; let complete = false; - ajax.post('/flibbertyJibbet', expected).subscribe( + ajax.post('/flibbertyJibbet', undefined).subscribe( (x) => { result = x; }, @@ -847,12 +833,11 @@ describe('ajax', () => { request.respondWith({ status: 204, - contentType: 'application/json', - responseType: 'json', - responseText: expected, + responseType: '', + responseText: '', }); - expect(result!.response).to.equal(expected); + expect(result!.response).to.equal(undefined); expect(complete).to.be.true; }); @@ -872,13 +857,13 @@ describe('ajax', () => { request.respondWith( { status: 200, - contentType: 'application/json', + responseType: 'json', responseText: JSON.stringify({}), }, - 3 + { uploadProgressTimes: 3 } ); - expect(spy).to.be.calledThrice; + expect(spy).to.be.called.callCount(4); }); it('should emit progress event when progressSubscriber is specified', function () { @@ -903,13 +888,13 @@ describe('ajax', () => { request.respondWith( { status: 200, - contentType: 'application/json', + responseType: 'json', responseText: JSON.stringify({}), }, - 3 + { uploadProgressTimes: 3 } ); - expect(spy).to.be.calledThrice; + expect(spy).to.be.called.callCount(4); }); }); @@ -945,18 +930,20 @@ describe('ajax', () => { try { request.ontimeout('ontimeout' as any); } catch (e) { - expect(e.message).to.equal(new AjaxTimeoutError(request as any, { - url: ajaxRequest.url, - method: 'GET', - headers: { - 'content-type': 'application/json;encoding=Utf-8', - }, - withCredentials: false, - async: true, - timeout: 0, - crossDomain: false, - responseType: 'json' - }).message); + expect(e.message).to.equal( + new AjaxTimeoutError(request as any, { + url: ajaxRequest.url, + method: 'GET', + headers: { + 'content-type': 'application/json;encoding=Utf-8', + }, + withCredentials: false, + async: true, + timeout: 0, + crossDomain: false, + responseType: 'json', + }).message + ); } delete root.XMLHttpRequest.prototype.ontimeout; }); @@ -1067,7 +1054,7 @@ describe('ajax', () => { request.respondWith({ status: 200, - contentType: 'application/json', + responseType: 'json', responseText: JSON.stringify(expected), }); @@ -1124,10 +1111,339 @@ describe('ajax', () => { }); }); }); + + describe('with includeDownloadProgress', () => { + it('should emit download progress', () => { + const results: any[] = []; + + ajax({ + method: 'GET', + url: '/flibbertyJibbett', + includeDownloadProgress: true, + }).subscribe({ + next: (value) => results.push(value), + complete: () => results.push('done'), + }); + + const mockXHR = MockXMLHttpRequest.mostRecent; + mockXHR.respondWith( + { + status: 200, + total: 5, + loaded: 5, + responseType: 'json', + responseText: JSON.stringify({ boo: 'I am a ghost' }), + }, + { uploadProgressTimes: 5, downloadProgressTimes: 5 } + ); + + const request = { + async: true, + body: undefined, + crossDomain: true, + headers: { + 'x-requested-with': 'XMLHttpRequest', + }, + includeDownloadProgress: true, + method: 'GET', + responseType: '', + timeout: 0, + url: '/flibbertyJibbett', + withCredentials: false, + }; + + expect(results).to.deep.equal([ + { + type: 'download_loadstart', + responseType: '', + response: undefined, + loaded: 0, + total: 5, + request, + status: 0, + xhr: mockXHR, + originalEvent: { type: 'loadstart', loaded: 0, total: 5 }, + }, + { + type: 'download_progress', + responseType: '', + response: undefined, + loaded: 1, + total: 5, + request, + status: 0, + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 1, total: 5 }, + }, + { + type: 'download_progress', + responseType: '', + response: undefined, + loaded: 2, + total: 5, + request, + status: 0, + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 2, total: 5 }, + }, + { + type: 'download_progress', + responseType: '', + response: undefined, + loaded: 3, + total: 5, + request, + status: 0, + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 3, total: 5 }, + }, + { + type: 'download_progress', + responseType: '', + response: undefined, + loaded: 4, + total: 5, + request, + status: 0, + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 4, total: 5 }, + }, + { + type: 'download_progress', + responseType: '', + response: undefined, + loaded: 5, + total: 5, + request, + status: 0, + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 5, total: 5 }, + }, + { + type: 'download_load', + loaded: 5, + total: 5, + request, + originalEvent: { type: 'load', loaded: 5, total: 5 }, + xhr: mockXHR, + response: { boo: 'I am a ghost' }, + responseType: 'json', + status: 200, + }, + 'done', // from completion. + ]); + }); + + it('should emit upload and download progress', () => { + const results: any[] = []; + + ajax({ + method: 'GET', + url: '/flibbertyJibbett', + includeUploadProgress: true, + includeDownloadProgress: true, + }).subscribe({ + next: (value) => results.push(value), + complete: () => results.push('done'), + }); + + const mockXHR = MockXMLHttpRequest.mostRecent; + mockXHR.respondWith( + { + status: 200, + total: 5, + loaded: 5, + responseType: 'json', + responseText: JSON.stringify({ boo: 'I am a ghost' }), + }, + { uploadProgressTimes: 5, downloadProgressTimes: 5 } + ); + + const request = { + async: true, + body: undefined, + crossDomain: true, + headers: { + 'x-requested-with': 'XMLHttpRequest', + }, + includeUploadProgress: true, + includeDownloadProgress: true, + method: 'GET', + responseType: '', + timeout: 0, + url: '/flibbertyJibbett', + withCredentials: false, + }; + + expect(results).to.deep.equal([ + { + type: 'upload_loadstart', + loaded: 0, + total: 5, + request, + status: 0, + response: undefined, + responseType: '', + xhr: mockXHR, + originalEvent: { type: 'loadstart', loaded: 0, total: 5 }, + }, + { + type: 'upload_progress', + loaded: 1, + total: 5, + request, + status: 0, + response: undefined, + responseType: '', + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 1, total: 5 }, + }, + { + type: 'upload_progress', + loaded: 2, + total: 5, + request, + status: 0, + response: undefined, + responseType: '', + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 2, total: 5 }, + }, + { + type: 'upload_progress', + loaded: 3, + total: 5, + request, + status: 0, + response: undefined, + responseType: '', + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 3, total: 5 }, + }, + { + type: 'upload_progress', + loaded: 4, + total: 5, + request, + status: 0, + response: undefined, + responseType: '', + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 4, total: 5 }, + }, + { + type: 'upload_progress', + loaded: 5, + total: 5, + request, + status: 0, + response: undefined, + responseType: '', + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 5, total: 5 }, + }, + { + type: 'upload_load', + loaded: 5, + total: 5, + request, + status: 0, + response: undefined, + responseType: '', + xhr: mockXHR, + originalEvent: { type: 'load', loaded: 5, total: 5 }, + }, + { + type: 'download_loadstart', + responseType: '', + response: undefined, + loaded: 0, + total: 5, + request, + status: 0, + xhr: mockXHR, + originalEvent: { type: 'loadstart', loaded: 0, total: 5 }, + }, + { + type: 'download_progress', + responseType: '', + response: undefined, + loaded: 1, + total: 5, + request, + status: 0, + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 1, total: 5 }, + }, + { + type: 'download_progress', + responseType: '', + response: undefined, + loaded: 2, + total: 5, + request, + status: 0, + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 2, total: 5 }, + }, + { + type: 'download_progress', + responseType: '', + response: undefined, + loaded: 3, + total: 5, + request, + status: 0, + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 3, total: 5 }, + }, + { + type: 'download_progress', + responseType: '', + response: undefined, + loaded: 4, + total: 5, + request, + status: 0, + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 4, total: 5 }, + }, + { + type: 'download_progress', + responseType: '', + response: undefined, + loaded: 5, + total: 5, + request, + status: 0, + xhr: mockXHR, + originalEvent: { type: 'progress', loaded: 5, total: 5 }, + }, + { + type: 'download_load', + loaded: 5, + total: 5, + request, + originalEvent: { type: 'load', loaded: 5, total: 5 }, + xhr: mockXHR, + response: { boo: 'I am a ghost' }, + responseType: 'json', + status: 200, + }, + 'done', // from completion. + ]); + }); + }); }); class MockXMLHttpRequest { - public static readonly DONE = 4; + static readonly DONE = 4; + + /** + * Set to `true` to test IE code paths. + */ + static noResponseProp = false; + private static requests: Array = []; private static recentRequest: MockXMLHttpRequest; @@ -1140,6 +1456,7 @@ class MockXMLHttpRequest { } static clearRequest(): void { + MockXMLHttpRequest.noResponseProp = false; MockXMLHttpRequest.requests.length = 0; MockXMLHttpRequest.recentRequest = null!; } @@ -1147,12 +1464,15 @@ class MockXMLHttpRequest { protected responseType: string = ''; private readyState: number = 0; - private async: boolean = true; + /** + * Used to test if `open` was called with `async` true or false. + */ + public async: boolean = true; protected status: any; // @ts-ignore: Property has no initializer and is not definitely assigned - protected responseText: string; - protected response: any; + protected responseText: string | undefined; + protected response: any = undefined; url: any; method: any; @@ -1173,6 +1493,9 @@ class MockXMLHttpRequest { constructor() { MockXMLHttpRequest.recentRequest = this; MockXMLHttpRequest.requests.push(this); + if (MockXMLHttpRequest.noResponseProp) { + delete this['response']; + } } // @ts-ignore: Property has no initializer and is not definitely assigned @@ -1214,45 +1537,70 @@ class MockXMLHttpRequest { this.requestHeaders[key] = value; } - protected jsonResponseValue(response: any) { - try { - this.response = JSON.parse(response.responseText); - } catch (err) { - throw new Error('unable to JSON.parse: \n' + response.responseText); - } - } - - protected defaultResponseValue() { - if (this.async === false) { - this.response = this.responseText; + respondWith( + response: { + status?: number; + responseText?: string | undefined; + responseType: XMLHttpRequestResponseType; + total?: number; + loaded?: number; + }, + config?: { uploadProgressTimes?: number; downloadProgressTimes?: number } + ): void { + const { uploadProgressTimes = 0, downloadProgressTimes = 0 } = config ?? {}; + + // Fake our upload progress first, if requested by the test. + if (uploadProgressTimes) { + this.triggerUploadEvent('loadstart', { type: 'loadstart', total: uploadProgressTimes, loaded: 0 }); + for (let i = 1; i <= uploadProgressTimes; i++) { + this.triggerUploadEvent('progress', { type: 'progress', total: uploadProgressTimes, loaded: i }); + } + this.triggerUploadEvent('load', { type: 'load', total: uploadProgressTimes, loaded: uploadProgressTimes }); } - } - respondWith(response: any, progressTimes?: number): void { - if (progressTimes) { - for (let i = 1; i <= progressTimes; ++i) { - this.triggerUploadEvent('progress', { type: 'ProgressEvent', total: progressTimes, loaded: i }); + // Fake our download progress + if (downloadProgressTimes) { + this.triggerEvent('loadstart', { type: 'loadstart', total: downloadProgressTimes, loaded: 0 }); + for (let i = 1; i <= downloadProgressTimes; i++) { + this.triggerEvent('progress', { type: 'progress', total: downloadProgressTimes, loaded: i }); } } + + // Set the readyState to 4. this.readyState = 4; + + // Default to OK 200. this.status = response.status || 200; this.responseText = response.responseText; - const responseType = response.responseType !== undefined ? response.responseType : this.responseType; - if (!('response' in response)) { - switch (responseType) { - case 'json': - this.jsonResponseValue(response); - break; - case 'text': - this.response = response.responseText; - break; - default: - this.defaultResponseValue(); - } + this.responseType = response.responseType; + + switch (this.responseType) { + case 'json': + try { + this.response = JSON.parse(response.responseText!); + } catch (err) { + // Ignore this is for testing if we get an invalid server + // response somehow, where responseType is "json" but the responseText + // is not JSON. In truth, we need to invert these tests to just use + // response, because `responseText` is a legacy path. + this.response = undefined; + } + break; + case 'text': + this.response = response.responseText; + break; + default: + // response remains undefined + break; } - // TODO: pass better event to onload. - this.triggerEvent('load'); - this.triggerEvent('readystatechange'); + + // We're testing old IE, forget all of that response property stuff. + if (MockXMLHttpRequest.noResponseProp) { + delete this['response']; + } + + this.triggerEvent('load', { type: 'load', total: response.total ?? 0, loaded: response.loaded ?? 0 }); + this.triggerEvent('readystatechange', { type: 'readystatechange' }); } triggerEvent(this: any, name: any, eventObj?: any): void { diff --git a/src/internal/ajax/AjaxResponse.ts b/src/internal/ajax/AjaxResponse.ts index 14683046c12..a91e0c25782 100644 --- a/src/internal/ajax/AjaxResponse.ts +++ b/src/internal/ajax/AjaxResponse.ts @@ -20,6 +20,10 @@ export class AjaxResponse { /** The responseType from the response. (For example: `""`, "arraybuffer"`, "blob"`, "document"`, "json"`, or `"text"`) */ readonly responseType: XMLHttpRequestResponseType; + readonly loaded: number; + + readonly total: number; + /** * A normalized response from an AJAX request. To get the data from the response, * you will want to read the `response` property. @@ -31,9 +35,16 @@ export class AjaxResponse { * @param xhr The `XMLHttpRequest` object used to make the request. This is useful for examining status code, etc. * @param request The request settings used to make the HTTP request. */ - constructor(public readonly originalEvent: Event, public readonly xhr: XMLHttpRequest, public readonly request: AjaxRequest) { - this.status = xhr.status; - this.responseType = xhr.responseType; + constructor( + public readonly originalEvent: ProgressEvent, + public readonly xhr: XMLHttpRequest, + public readonly request: AjaxRequest, + public readonly type = 'download_load' + ) { + this.status = xhr.status ?? 0; + this.responseType = xhr.responseType ?? ''; this.response = getXHRResponse(xhr); + this.loaded = originalEvent.loaded; + this.total = originalEvent.total; } } diff --git a/src/internal/ajax/ajax.ts b/src/internal/ajax/ajax.ts index e078b89f1f8..823698e4450 100644 --- a/src/internal/ajax/ajax.ts +++ b/src/internal/ajax/ajax.ts @@ -269,7 +269,7 @@ export const ajax: AjaxCreationMethod = (() => { })(); export function fromAjax(config: AjaxConfig): Observable> { - return new Observable>((destination) => { + return new Observable((destination) => { // Normalize the headers. We're going to make them all lowercase, since // Headers are case insenstive by design. This makes it easier to verify // that we aren't setting or sending duplicates. @@ -315,7 +315,7 @@ export function fromAjax(config: AjaxConfig): Observable> { withCredentials: false, method: 'GET', timeout: 0, - responseType: 'json' as XMLHttpRequestResponseType, + responseType: '' as XMLHttpRequestResponseType, // Override with passed user values ...config, @@ -343,7 +343,7 @@ export function fromAjax(config: AjaxConfig): Observable> { // Otherwise the progress events will not fire. /////////////////////////////////////////////////// - const progressSubscriber = config.progressSubscriber; + const { progressSubscriber, includeDownloadProgress = false, includeUploadProgress = false } = config; xhr.ontimeout = () => { const timeoutError = new AjaxTimeoutError(xhr, _request); @@ -351,9 +351,34 @@ export function fromAjax(config: AjaxConfig): Observable> { destination.error(timeoutError); }; - if (progressSubscriber) { - xhr.upload.onprogress = (e: ProgressEvent) => { - progressSubscriber.next?.(e); + if (progressSubscriber || includeUploadProgress) { + if (includeUploadProgress) { + xhr.upload.onloadstart = (event: ProgressEvent) => { + destination.next(new AjaxResponse(event, xhr, _request, 'upload_loadstart')); + }; + } + + xhr.upload.onprogress = (event: ProgressEvent) => { + progressSubscriber?.next?.(event); + if (includeUploadProgress) { + destination.next(new AjaxResponse(event, xhr, _request, 'upload_progress')); + } + }; + + xhr.upload.onload = (event: ProgressEvent) => { + progressSubscriber?.next?.(event); + if (includeUploadProgress) { + destination.next(new AjaxResponse(event, xhr, _request, 'upload_load')); + } + }; + } + + if (includeDownloadProgress) { + xhr.onloadstart = (event: ProgressEvent) => { + destination.next(new AjaxResponse(event, xhr, _request, 'download_loadstart')); + }; + xhr.onprogress = (event: ProgressEvent) => { + destination.next(new AjaxResponse(event, xhr, _request, 'download_progress')); }; } @@ -362,7 +387,7 @@ export function fromAjax(config: AjaxConfig): Observable> { destination.error(new AjaxError('ajax error', xhr, _request)); }; - xhr.onload = (e: ProgressEvent) => { + xhr.onload = (event: ProgressEvent) => { // 4xx and 5xx should error (https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html) if (xhr.status < 400) { progressSubscriber?.complete?.(); @@ -372,15 +397,16 @@ export function fromAjax(config: AjaxConfig): Observable> { // This can throw in IE, because we end up needing to do a JSON.parse // of the response in some cases to produce object we'd expect from // modern browsers. - response = new AjaxResponse(e, xhr, _request); + response = new AjaxResponse(event, xhr, _request, 'download_load'); } catch (err) { destination.error(err); return; } + destination.next(response); destination.complete(); } else { - progressSubscriber?.error?.(e); + progressSubscriber?.error?.(event); destination.error(new AjaxError('ajax error ' + xhr.status, xhr, _request)); } }; diff --git a/src/internal/ajax/types.ts b/src/internal/ajax/types.ts index 5becc29b7c8..6cde6feacca 100644 --- a/src/internal/ajax/types.ts +++ b/src/internal/ajax/types.ts @@ -174,4 +174,22 @@ export interface AjaxConfig { * the HTTP response comes back. */ progressSubscriber?: PartialObserver; + + /** + * If `true`, will emit all download progress and load complete events as {@link AjaxProgressEvents} + * from the observable. The final download event will also be emitted as a {@link AjaxDownloadCompleteEvent} + * + * If both this and {@link includeUploadProgress} are `false`, then only the {@link AjaxResponse} will + * be emitted from the resulting observable. + */ + includeDownloadProgress?: boolean; + + /** + * If `true`, will emit all upload progress and load complete events as {@link AjaxUploadProgressEvents} + * from the observable. The final download event will also be emitted as a {@link AjaxDownloadCompleteEvent} + * + * If both this and {@link includeDownloadProgress} are `false`, then only the {@link AjaxResponse} will + * be emitted from the resulting observable. + */ + includeUploadProgress?: boolean; }