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); | |
* }) | |
* ); | |
* ``` | |
*/ | |
<T>(config: AjaxConfig): Observable<AjaxResponse<T>>; | |
/** | |
* 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); | |
* }) | |
* ); | |
* ``` | |
*/ | |
<T>(url: string): Observable<AjaxResponse<T>>; | |
/** | |
* 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<T>(url: string, headers?: Record<string, string>): Observable<AjaxResponse<T>>; | |
/** | |
* 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<T>(url: string, body?: any, headers?: Record<string, string>): Observable<AjaxResponse<T>>; | |
/** | |
* 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<T>(url: string, body?: any, headers?: Record<string, string>): Observable<AjaxResponse<T>>; | |
/** | |
* 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<T>(url: string, body?: any, headers?: Record<string, string>): Observable<AjaxResponse<T>>; | |
/** | |
* 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<T>(url: string, headers?: Record<string, string>): Observable<AjaxResponse<T>>; | |
/** | |
* 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<T>(url: string, headers?: Record<string, string>): Observable<T>; | |
} | |
function ajaxGet<T>(url: string, headers?: Record<string, string>): Observable<AjaxResponse<T>> { | |
return ajax({ method: 'GET', url, headers }); | |
} | |
function ajaxPost<T>(url: string, body?: any, headers?: Record<string, string>): Observable<AjaxResponse<T>> { | |
return ajax({ method: 'POST', url, body, headers }); | |
} | |
function ajaxDelete<T>(url: string, headers?: Record<string, string>): Observable<AjaxResponse<T>> { | |
return ajax({ method: 'DELETE', url, headers }); | |
} | |
function ajaxPut<T>(url: string, body?: any, headers?: Record<string, string>): Observable<AjaxResponse<T>> { | |
return ajax({ method: 'PUT', url, body, headers }); | |
} | |
function ajaxPatch<T>(url: string, body?: any, headers?: Record<string, string>): Observable<AjaxResponse<T>> { | |
return ajax({ method: 'PATCH', url, body, headers }); | |
} | |
const mapResponse = map((x: AjaxResponse<any>) => x.response); | |
function ajaxGetJSON<T>(url: string, headers?: Record<string, string>): Observable<T> { | |
return mapResponse( | |
ajax<T>({ | |
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 = <T>(urlOrConfig: string | AjaxConfig) => { | |
const config: AjaxConfig = | |
typeof urlOrConfig === 'string' | |
? { | |
url: urlOrConfig, | |
} | |
: urlOrConfig; | |
return fromAjax<T>(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<T>(init: AjaxConfig): Observable<AjaxResponse<T>> { | |
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 preexisting 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<string, any> = {}; | |
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<AjaxRequest> = { | |
...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<T>(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<T>; | |
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<string, string>) { | |
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; | |
} | |