import { map } from '../operators/map'; import { Observable } from '../Observable'; import { AjaxConfig, AjaxRequest, AjaxDirection, ProgressEventType } from './types'; import { AjaxResponse } from './AjaxResponse'; import { AjaxTimeoutError, AjaxError } from './errors'; export interface AjaxCreationMethod { /** * Creates an observable that will perform an AJAX request using the * [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in * global scope by default. * * This is the most configurable option, and the basis for all other AJAX calls in the library. * * ## Example * * ```ts * import { ajax } from 'rxjs/ajax'; * import { map, catchError, of } from 'rxjs'; * * const obs$ = ajax({ * method: 'GET', * url: 'https://api.github.com/users?per_page=5', * responseType: 'json' * }).pipe( * map(userResponse => console.log('users: ', userResponse)), * catchError(error => { * console.log('error: ', error); * return of(error); * }) * ); * ``` */ (config: AjaxConfig): Observable>; /** * Perform an HTTP GET using the * [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in * global scope. Defaults to a `responseType` of `"json"`. * * ## Example * * ```ts * import { ajax } from 'rxjs/ajax'; * import { map, catchError, of } from 'rxjs'; * * const obs$ = ajax('https://api.github.com/users?per_page=5').pipe( * map(userResponse => console.log('users: ', userResponse)), * catchError(error => { * console.log('error: ', error); * return of(error); * }) * ); * ``` */ (url: string): Observable>; /** * Performs an HTTP GET using the * [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in * global scope by default, and a `responseType` of `"json"`. * * @param url The URL to get the resource from * @param headers Optional headers. Case-Insensitive. */ get(url: string, headers?: Record): Observable>; /** * Performs an HTTP POST using the * [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in * global scope by default, and a `responseType` of `"json"`. * * Before sending the value passed to the `body` argument, it is automatically serialized * based on the specified `responseType`. By default, a JavaScript object will be serialized * to JSON. A `responseType` of `application/x-www-form-urlencoded` will flatten any provided * dictionary object to a url-encoded string. * * @param url The URL to get the resource from * @param body The content to send. The body is automatically serialized. * @param headers Optional headers. Case-Insensitive. */ post(url: string, body?: any, headers?: Record): Observable>; /** * Performs an HTTP PUT using the * [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in * global scope by default, and a `responseType` of `"json"`. * * Before sending the value passed to the `body` argument, it is automatically serialized * based on the specified `responseType`. By default, a JavaScript object will be serialized * to JSON. A `responseType` of `application/x-www-form-urlencoded` will flatten any provided * dictionary object to a url-encoded string. * * @param url The URL to get the resource from * @param body The content to send. The body is automatically serialized. * @param headers Optional headers. Case-Insensitive. */ put(url: string, body?: any, headers?: Record): Observable>; /** * Performs an HTTP PATCH using the * [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in * global scope by default, and a `responseType` of `"json"`. * * Before sending the value passed to the `body` argument, it is automatically serialized * based on the specified `responseType`. By default, a JavaScript object will be serialized * to JSON. A `responseType` of `application/x-www-form-urlencoded` will flatten any provided * dictionary object to a url-encoded string. * * @param url The URL to get the resource from * @param body The content to send. The body is automatically serialized. * @param headers Optional headers. Case-Insensitive. */ patch(url: string, body?: any, headers?: Record): Observable>; /** * Performs an HTTP DELETE using the * [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in * global scope by default, and a `responseType` of `"json"`. * * @param url The URL to get the resource from * @param headers Optional headers. Case-Insensitive. */ delete(url: string, headers?: Record): Observable>; /** * Performs an HTTP GET using the * [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in * global scope by default, and returns the hydrated JavaScript object from the * response. * * @param url The URL to get the resource from * @param headers Optional headers. Case-Insensitive. */ getJSON(url: string, headers?: Record): Observable; } function ajaxGet(url: string, headers?: Record): Observable> { return ajax({ method: 'GET', url, headers }); } function ajaxPost(url: string, body?: any, headers?: Record): Observable> { return ajax({ method: 'POST', url, body, headers }); } function ajaxDelete(url: string, headers?: Record): Observable> { return ajax({ method: 'DELETE', url, headers }); } function ajaxPut(url: string, body?: any, headers?: Record): Observable> { return ajax({ method: 'PUT', url, body, headers }); } function ajaxPatch(url: string, body?: any, headers?: Record): Observable> { return ajax({ method: 'PATCH', url, body, headers }); } const mapResponse = map((x: AjaxResponse) => x.response); function ajaxGetJSON(url: string, headers?: Record): Observable { return mapResponse( ajax({ method: 'GET', url, headers, }) ); } /** * There is an ajax operator on the Rx object. * * It creates an observable for an Ajax request with either a request object with * url, headers, etc or a string for a URL. * * ## Examples * * Using `ajax()` to fetch the response object that is being returned from API * * ```ts * import { ajax } from 'rxjs/ajax'; * import { map, catchError, of } from 'rxjs'; * * const obs$ = ajax('https://api.github.com/users?per_page=5').pipe( * map(userResponse => console.log('users: ', userResponse)), * catchError(error => { * console.log('error: ', error); * return of(error); * }) * ); * * obs$.subscribe({ * next: value => console.log(value), * error: err => console.log(err) * }); * ``` * * Using `ajax.getJSON()` to fetch data from API * * ```ts * import { ajax } from 'rxjs/ajax'; * import { map, catchError, of } from 'rxjs'; * * const obs$ = ajax.getJSON('https://api.github.com/users?per_page=5').pipe( * map(userResponse => console.log('users: ', userResponse)), * catchError(error => { * console.log('error: ', error); * return of(error); * }) * ); * * obs$.subscribe({ * next: value => console.log(value), * error: err => console.log(err) * }); * ``` * * Using `ajax()` with object as argument and method POST with a two seconds delay * * ```ts * import { ajax } from 'rxjs/ajax'; * import { map, catchError, of } from 'rxjs'; * * const users = ajax({ * url: 'https://httpbin.org/delay/2', * method: 'POST', * headers: { * 'Content-Type': 'application/json', * 'rxjs-custom-header': 'Rxjs' * }, * body: { * rxjs: 'Hello World!' * } * }).pipe( * map(response => console.log('response: ', response)), * catchError(error => { * console.log('error: ', error); * return of(error); * }) * ); * * users.subscribe({ * next: value => console.log(value), * error: err => console.log(err) * }); * ``` * * Using `ajax()` to fetch. An error object that is being returned from the request * * ```ts * import { ajax } from 'rxjs/ajax'; * import { map, catchError, of } from 'rxjs'; * * const obs$ = ajax('https://api.github.com/404').pipe( * map(userResponse => console.log('users: ', userResponse)), * catchError(error => { * console.log('error: ', error); * return of(error); * }) * ); * * obs$.subscribe({ * next: value => console.log(value), * error: err => console.log(err) * }); * ``` */ export const ajax: AjaxCreationMethod = (() => { const create = (urlOrConfig: string | AjaxConfig) => { const config: AjaxConfig = typeof urlOrConfig === 'string' ? { url: urlOrConfig, } : urlOrConfig; return fromAjax(config); }; create.get = ajaxGet; create.post = ajaxPost; create.delete = ajaxDelete; create.put = ajaxPut; create.patch = ajaxPatch; create.getJSON = ajaxGetJSON; return create; })(); const UPLOAD = 'upload'; const DOWNLOAD = 'download'; const LOADSTART = 'loadstart'; const PROGRESS = 'progress'; const LOAD = 'load'; export function fromAjax(init: AjaxConfig): Observable> { return new Observable((destination) => { const config = { // Defaults async: true, crossDomain: false, withCredentials: false, method: 'GET', timeout: 0, responseType: 'json' as XMLHttpRequestResponseType, ...init, }; const { queryParams, body: configuredBody, headers: configuredHeaders } = config; let url = config.url; if (!url) { throw new TypeError('url is required'); } if (queryParams) { let searchParams: URLSearchParams; if (url.includes('?')) { // If the user has passed a URL with a querystring already in it, // we need to combine them. So we're going to split it. There // should only be one `?` in a valid URL. const parts = url.split('?'); if (2 < parts.length) { throw new TypeError('invalid url'); } // Add the passed queryParams to the params already in the url provided. searchParams = new URLSearchParams(parts[1]); // queryParams is converted to any because the runtime is *much* more permissive than // the types are. new URLSearchParams(queryParams as any).forEach((value, key) => searchParams.set(key, value)); // We have to do string concatenation here, because `new URL(url)` does // not like relative URLs like `/this` without a base url, which we can't // specify, nor can we assume `location` will exist, because of node. url = parts[0] + '?' + searchParams; } else { // There is no pre-existing querystring, so we can just use URLSearchParams // to convert the passed queryParams into the proper format and encodings. // queryParams is converted to any because the runtime is *much* more permissive than // the types are. searchParams = new URLSearchParams(queryParams as any); url = url + '?' + searchParams; } } // Normalize the headers. We're going to make them all lowercase, since // Headers are case insensitive by design. This makes it easier to verify // that we aren't setting or sending duplicates. const headers: Record = {}; if (configuredHeaders) { for (const key in configuredHeaders) { if (configuredHeaders.hasOwnProperty(key)) { headers[key.toLowerCase()] = configuredHeaders[key]; } } } const crossDomain = config.crossDomain; // Set the x-requested-with header. This is a non-standard header that has // come to be a de facto standard for HTTP requests sent by libraries and frameworks // using XHR. However, we DO NOT want to set this if it is a CORS request. This is // because sometimes this header can cause issues with CORS. To be clear, // None of this is necessary, it's only being set because it's "the thing libraries do" // Starting back as far as JQuery, and continuing with other libraries such as Angular 1, // Axios, et al. if (!crossDomain && !('x-requested-with' in headers)) { headers['x-requested-with'] = 'XMLHttpRequest'; } // Allow users to provide their XSRF cookie name and the name of a custom header to use to // send the cookie. const { withCredentials, xsrfCookieName, xsrfHeaderName } = config; if ((withCredentials || !crossDomain) && xsrfCookieName && xsrfHeaderName) { const xsrfCookie = document?.cookie.match(new RegExp(`(^|;\\s*)(${xsrfCookieName})=([^;]*)`))?.pop() ?? ''; if (xsrfCookie) { headers[xsrfHeaderName] = xsrfCookie; } } // Examine the body and determine whether or not to serialize it // and set the content-type in `headers`, if we're able. const body = extractContentTypeAndMaybeSerializeBody(configuredBody, headers); // The final request settings. const _request: Readonly = { ...config, // Set values we ensured above url, headers, body, }; let xhr: XMLHttpRequest; // Create our XHR so we can get started. xhr = init.createXHR ? init.createXHR() : new XMLHttpRequest(); { /////////////////////////////////////////////////// // 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. /////////////////////////////////////////////////// const { progressSubscriber, includeDownloadProgress = false, includeUploadProgress = false } = init; /** * Wires up an event handler that will emit an error when fired. Used * for timeout and abort events. * @param type The type of event we're treating as an error * @param errorFactory A function that creates the type of error to emit. */ const addErrorEvent = (type: string, errorFactory: () => any) => { xhr.addEventListener(type, () => { const error = errorFactory(); progressSubscriber?.error?.(error); destination.error(error); }); }; // If the request times out, handle errors appropriately. addErrorEvent('timeout', () => new AjaxTimeoutError(xhr, _request)); // If the request aborts (due to a network disconnection or the like), handle // it as an error. addErrorEvent('abort', () => new AjaxError('aborted', xhr, _request)); /** * Creates a response object to emit to the consumer. * @param direction the direction related to the event. Prefixes the event `type` in the * `AjaxResponse` object with "upload_" for events related to uploading and "download_" * for events related to downloading. * @param event the actual event object. */ const createResponse = (direction: AjaxDirection, event: ProgressEvent) => new AjaxResponse(event, xhr, _request, `${direction}_${event.type as ProgressEventType}` as const); /** * Wires up an event handler that emits a Response object to the consumer, used for * all events that emit responses, loadstart, progress, and load. * Note that download load handling is a bit different below, because it has * more logic it needs to run. * @param target The target, either the XHR itself or the Upload object. * @param type The type of event to wire up * @param direction The "direction", used to prefix the response object that is * emitted to the consumer. (e.g. "upload_" or "download_") */ const addProgressEvent = (target: any, type: string, direction: AjaxDirection) => { target.addEventListener(type, (event: ProgressEvent) => { destination.next(createResponse(direction, event)); }); }; if (includeUploadProgress) { [LOADSTART, PROGRESS, LOAD].forEach((type) => addProgressEvent(xhr.upload, type, UPLOAD)); } if (progressSubscriber) { [LOADSTART, PROGRESS].forEach((type) => xhr.upload.addEventListener(type, (e: any) => progressSubscriber?.next?.(e))); } if (includeDownloadProgress) { [LOADSTART, PROGRESS].forEach((type) => addProgressEvent(xhr, type, DOWNLOAD)); } const emitError = (status?: number) => { const msg = 'ajax error' + (status ? ' ' + status : ''); destination.error(new AjaxError(msg, xhr, _request)); }; xhr.addEventListener('error', (e) => { progressSubscriber?.error?.(e); emitError(); }); xhr.addEventListener(LOAD, (event) => { const { status } = xhr; // 4xx and 5xx should error (https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html) if (status < 400) { progressSubscriber?.complete?.(); let response: AjaxResponse; try { // 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 = createResponse(DOWNLOAD, event); } catch (err) { destination.error(err); return; } destination.next(response); destination.complete(); } else { progressSubscriber?.error?.(event); emitError(status); } }); } const { user, method, async } = _request; // open XHR if (user) { xhr.open(method, url, async, user, _request.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; } if ('withCredentials' in xhr) { xhr.withCredentials = _request.withCredentials; } // set headers for (const key in headers) { if (headers.hasOwnProperty(key)) { xhr.setRequestHeader(key, headers[key]); } } // finally send the request if (body) { xhr.send(body); } else { xhr.send(); } return () => { if (xhr && xhr.readyState !== 4 /*XHR done*/) { xhr.abort(); } }; }); } /** * Examines the body to determine if we need to serialize it for them or not. * If the body is a type that XHR handles natively, we just allow it through, * otherwise, if the body is something that *we* can serialize for the user, * we will serialize it, and attempt to set the `content-type` header, if it's * not already set. * @param body The body passed in by the user * @param headers The normalized headers */ function extractContentTypeAndMaybeSerializeBody(body: any, headers: Record) { if ( !body || typeof body === 'string' || isFormData(body) || isURLSearchParams(body) || isArrayBuffer(body) || isFile(body) || isBlob(body) || isReadableStream(body) ) { // The XHR instance itself can handle serializing these, and set the content-type for us // so we don't need to do that. https://xhr.spec.whatwg.org/#the-send()-method return body; } if (isArrayBufferView(body)) { // This is a typed array (e.g. Float32Array or Uint8Array), or a DataView. // XHR can handle this one too: https://fetch.spec.whatwg.org/#concept-bodyinit-extract return body.buffer; } if (typeof body === 'object') { // If we have made it here, this is an object, probably a POJO, and we'll try // to serialize it for them. If this doesn't work, it will throw, obviously, which // is okay. The workaround for users would be to manually set the body to their own // serialized string (accounting for circular references or whatever), then set // the content-type manually as well. headers['content-type'] = headers['content-type'] ?? 'application/json;charset=utf-8'; return JSON.stringify(body); } // If we've gotten past everything above, this is something we don't quite know how to // handle. Throw an error. This will be caught and emitted from the observable. throw new TypeError('Unknown body type'); } const _toString = Object.prototype.toString; function toStringCheck(obj: any, name: string): boolean { return _toString.call(obj) === `[object ${name}]`; } function isArrayBuffer(body: any): body is ArrayBuffer { return toStringCheck(body, 'ArrayBuffer'); } function isFile(body: any): body is File { return toStringCheck(body, 'File'); } function isBlob(body: any): body is Blob { return toStringCheck(body, 'Blob'); } function isArrayBufferView(body: any): body is ArrayBufferView { return typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(body); } function isFormData(body: any): body is FormData { return typeof FormData !== 'undefined' && body instanceof FormData; } function isURLSearchParams(body: any): body is URLSearchParams { return typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams; } function isReadableStream(body: any): body is ReadableStream { return typeof ReadableStream !== 'undefined' && body instanceof ReadableStream; }