/** @prettier */ import { Observable } from '../../Observable'; import { Subscriber } from '../../Subscriber'; import { TeardownLogic, PartialObserver } from '../../types'; export interface AjaxRequest { url?: string; body?: any; user?: string; async?: boolean; method?: string; headers?: object; timeout?: number; password?: string; hasContent?: boolean; crossDomain?: boolean; withCredentials?: boolean; createXHR?: () => XMLHttpRequest; progressSubscriber?: PartialObserver; responseType?: string; } function isFormData(body: any): body is FormData { return typeof FormData !== 'undefined' && body instanceof FormData; } /** * We need this JSDoc comment for affecting ESDoc. * @extends {Ignored} * @hide true */ export class AjaxObservable extends Observable { private request: AjaxRequest; constructor(urlOrRequest: string | AjaxRequest) { super(); const request: AjaxRequest = { async: true, createXHR: () => new XMLHttpRequest(), crossDomain: true, withCredentials: false, headers: {}, method: 'GET', responseType: 'json', timeout: 0, }; if (typeof urlOrRequest === 'string') { request.url = urlOrRequest; } else { for (const prop in urlOrRequest) { if (urlOrRequest.hasOwnProperty(prop)) { (request as any)[prop] = (urlOrRequest as any)[prop]; } } } this.request = request; } /** @deprecated This is an internal implementation detail, do not use. */ _subscribe(subscriber: Subscriber): TeardownLogic { return new AjaxSubscriber(subscriber, this.request); } } /** * We need this JSDoc comment for affecting ESDoc. * @ignore * @extends {Ignored} */ export class AjaxSubscriber extends Subscriber { // @ts-ignore: Property has no initializer and is not definitely assigned private xhr: XMLHttpRequest; private done: boolean = false; constructor(destination: Subscriber, public request: AjaxRequest) { super(destination); const headers = (request.headers = request.headers || {}); // force CORS if requested if (!request.crossDomain && !this.getHeader(headers, 'X-Requested-With')) { (headers as any)['X-Requested-With'] = 'XMLHttpRequest'; } // ensure content type is set let contentTypeHeader = this.getHeader(headers, 'Content-Type'); if (!contentTypeHeader && typeof request.body !== 'undefined' && !isFormData(request.body)) { (headers as any)['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'; } // properly serialize body request.body = this.serializeBody(request.body, this.getHeader(request.headers, 'Content-Type')); this.send(); } next(e: Event): void { this.done = true; const destination = this.destination as Subscriber; let result: AjaxResponse; try { result = new AjaxResponse(e, this.xhr, this.request); } catch (err) { return destination.error(err); } destination.next(result); } private send(): void { const { request, request: { user, method, url, async, password, headers, body }, } = this; try { const xhr = (this.xhr = request.createXHR!()); // set up the events before open XHR // https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest // You need to add the event listeners before calling open() on the request. // Otherwise the progress events will not fire. this.setupEvents(xhr, request); // open XHR if (user) { xhr.open(method!, url!, async!, user, password); } else { xhr.open(method!, url!, async!); } // timeout, responseType and withCredentials can be set once the XHR is open if (async) { xhr.timeout = request.timeout!; xhr.responseType = request.responseType as any; } if ('withCredentials' in xhr) { xhr.withCredentials = !!request.withCredentials; } // set headers this.setHeaders(xhr, headers!); // finally send the request if (body) { xhr.send(body); } else { xhr.send(); } } catch (err) { this.error(err); } } private serializeBody(body: any, contentType?: string) { if (!body || typeof body === 'string') { return body; } else if (isFormData(body)) { return body; } if (contentType) { const splitIndex = contentType.indexOf(';'); if (splitIndex !== -1) { contentType = contentType.substring(0, splitIndex); } } switch (contentType) { case 'application/x-www-form-urlencoded': return Object.keys(body) .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(body[key])}`) .join('&'); case 'application/json': return JSON.stringify(body); default: return body; } } private setHeaders(xhr: XMLHttpRequest, headers: Object) { for (let key in headers) { if (headers.hasOwnProperty(key)) { xhr.setRequestHeader(key, (headers as any)[key]); } } } private getHeader(headers: {}, headerName: string): any { for (let key in headers) { if (key.toLowerCase() === headerName.toLowerCase()) { return (headers as any)[key]; } } return undefined; } private setupEvents(xhr: XMLHttpRequest, request: AjaxRequest) { const progressSubscriber = request.progressSubscriber; xhr.ontimeout = (e: ProgressEvent) => { progressSubscriber?.error?.(e); let error; try { error = new AjaxTimeoutError(xhr, request); // TODO: Make betterer. } catch (err) { error = err; } this.error(error); }; if (progressSubscriber) { xhr.upload.onprogress = (e: ProgressEvent) => { progressSubscriber.next?.(e); }; } xhr.onerror = (e: ProgressEvent) => { progressSubscriber?.error?.(e); this.error(new AjaxError('ajax error', xhr, request)); }; xhr.onload = (e: ProgressEvent) => { // 4xx and 5xx should error (https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html) if (xhr.status < 400) { progressSubscriber?.complete?.(); this.next(e); this.complete(); } else { progressSubscriber?.error?.(e); let error; try { error = new AjaxError('ajax error ' + xhr.status, xhr, request); } catch (err) { error = err; } this.error(error); } }; } unsubscribe() { const { done, xhr } = this; if (!done && xhr && xhr.readyState !== 4 && typeof xhr.abort === 'function') { xhr.abort(); } super.unsubscribe(); } } /** * A normalized AJAX response. * * @see {@link ajax} * * @class AjaxResponse */ export class AjaxResponse { /** @type {number} The HTTP status code */ status: number; /** @type {string|ArrayBuffer|Document|object|any} The response data */ response: any; /** @type {string} The raw responseText */ // @ts-ignore: Property has no initializer and is not definitely assigned responseText: string; /** @type {string} The responseType (e.g. 'json', 'arraybuffer', or 'xml') */ responseType: string; constructor(public originalEvent: Event, public xhr: XMLHttpRequest, public request: AjaxRequest) { this.status = xhr.status; this.responseType = xhr.responseType || request.responseType!; this.response = getXHRResponse(xhr); } } export type AjaxErrorNames = 'AjaxError' | 'AjaxTimeoutError'; /** * A normalized AJAX error. * * @see {@link ajax} * * @class AjaxError */ export interface AjaxError extends Error { /** * The XHR instance associated with the error */ xhr: XMLHttpRequest; /** * The AjaxRequest associated with the error */ request: AjaxRequest; /** *The HTTP status code */ status: number; /** *The responseType (e.g. 'json', 'arraybuffer', or 'xml') */ responseType: XMLHttpRequestResponseType; /** * The response data */ response: any; } export interface AjaxErrorCtor { /** * Internal use only. Do not manually create instances of this type. * @internal */ new (message: string, xhr: XMLHttpRequest, request: AjaxRequest): AjaxError; } const AjaxErrorImpl = (() => { function AjaxErrorImpl(this: any, message: string, xhr: XMLHttpRequest, request: AjaxRequest): AjaxError { Error.call(this); this.message = message; this.name = 'AjaxError'; this.xhr = xhr; this.request = request; this.status = xhr.status; this.responseType = xhr.responseType; let response: any; try { response = getXHRResponse(xhr); } catch (err) { response = xhr.responseText; } this.response = response; return this; } AjaxErrorImpl.prototype = Object.create(Error.prototype); return AjaxErrorImpl; })(); /** * Thrown when an error occurs during an AJAX request. * This is only exported because it is useful for checking to see if an error * is an `instanceof AjaxError`. DO NOT create new instances of `AjaxError` with * the constructor. * * @class AjaxError * @see ajax */ export const AjaxError: AjaxErrorCtor = AjaxErrorImpl as any; function getXHRResponse(xhr: XMLHttpRequest) { switch (xhr.responseType) { case 'json': { if ('response' in xhr) { return xhr.response; } else { // IE const ieXHR: any = xhr; return JSON.parse(ieXHR.responseText); } } case 'document': return xhr.responseXML; case 'text': default: { if ('response' in xhr) { return xhr.response; } else { // IE const ieXHR: any = xhr; return ieXHR.responseText; } } } } export interface AjaxTimeoutError extends AjaxError {} export interface AjaxTimeoutErrorCtor { /** * Internal use only. Do not manually create instances of this type. * @internal */ new (xhr: XMLHttpRequest, request: AjaxRequest): AjaxTimeoutError; } const AjaxTimeoutErrorImpl = (() => { function AjaxTimeoutErrorImpl(this: any, xhr: XMLHttpRequest, request: AjaxRequest) { AjaxError.call(this, 'ajax timeout', xhr, request); this.name = 'AjaxTimeoutError'; return this; } AjaxTimeoutErrorImpl.prototype = Object.create(AjaxError.prototype); return AjaxTimeoutErrorImpl; })(); /** * Thrown when an AJAX request timesout. Not to be confused with {@link TimeoutError}. * * This is exported only because it is useful for checking to see if errors are an * `instanceof AjaxTimeoutError`. DO NOT use the constructor to create an instance of * this type. * * @class AjaxTimeoutError * @see ajax */ export const AjaxTimeoutError: AjaxTimeoutErrorCtor = AjaxTimeoutErrorImpl as any;