happy-dom
Version:
Happy DOM is a JavaScript implementation of a web browser without its graphical user interface. It includes many web standards from WHATWG DOM and HTML.
578 lines (509 loc) • 17.6 kB
text/typescript
import XMLHttpRequestEventTarget from './XMLHttpRequestEventTarget.js';
import * as PropertySymbol from '../PropertySymbol.js';
import XMLHttpRequestReadyStateEnum from './XMLHttpRequestReadyStateEnum.js';
import Event from '../event/Event.js';
import type Document from '../nodes/document/Document.js';
import type Blob from '../file/Blob.js';
import type XMLHttpRequestUpload from './XMLHttpRequestUpload.js';
import DOMException from '../exception/DOMException.js';
import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js';
import XMLHttpResponseTypeEnum from './XMLHttpResponseTypeEnum.js';
import ErrorEvent from '../event/events/ErrorEvent.js';
import Fetch from '../fetch/Fetch.js';
import SyncFetch from '../fetch/SyncFetch.js';
import type Request from '../fetch/Request.js';
import type ISyncResponse from '../fetch/types/ISyncResponse.js';
import type AbortController from '../fetch/AbortController.js';
import ProgressEvent from '../event/events/ProgressEvent.js';
import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js';
import type { TRequestBody } from '../fetch/types/TRequestBody.js';
import XMLHttpRequestResponseDataParser from './XMLHttpRequestResponseDataParser.js';
import FetchRequestHeaderUtility from '../fetch/utilities/FetchRequestHeaderUtility.js';
import type Response from '../fetch/Response.js';
import WindowBrowserContext from '../window/WindowBrowserContext.js';
import type BrowserWindow from '../window/BrowserWindow.js';
import BrowserErrorCaptureEnum from '../browser/enums/BrowserErrorCaptureEnum.js';
/**
* XMLHttpRequest.
*
* Based on:
* https://github.com/mjwwit/node-XMLHttpRequest/blob/master/lib/XMLHttpRequest.js
*/
export default class XMLHttpRequest extends XMLHttpRequestEventTarget {
// Injected by WindowContextClassExtender
protected declare [PropertySymbol.window]: BrowserWindow;
// Constants
public static UNSENT = XMLHttpRequestReadyStateEnum.unsent;
public static OPENED = XMLHttpRequestReadyStateEnum.opened;
public static HEADERS_RECEIVED = XMLHttpRequestReadyStateEnum.headersReceived;
public static LOADING = XMLHttpRequestReadyStateEnum.loading;
public static DONE = XMLHttpRequestReadyStateEnum.done;
// Public properties
public upload: XMLHttpRequestUpload = new this[PropertySymbol.window].XMLHttpRequestUpload();
public withCredentials = false;
// Private properties
#async = true;
#abortController: AbortController | null = null;
#aborted = false;
#request: Request | null = null;
#response: Response | ISyncResponse | null = null;
#responseType: XMLHttpResponseTypeEnum | '' = '';
#responseBody: ArrayBuffer | Blob | Document | object | string | null = null;
#accumulatedData: Buffer = Buffer.from([]);
#readyState: XMLHttpRequestReadyStateEnum = XMLHttpRequestReadyStateEnum.unsent;
#overriddenMimeType: string | null = null;
/**
* Constructor.
*/
constructor() {
super();
if (!this[PropertySymbol.window]) {
throw new TypeError(
`Failed to construct '${this.constructor.name}': '${this.constructor.name}' was constructed outside a Window context.`
);
}
}
/**
* Returns the status.
*
* @returns Status.
*/
public get status(): number {
return this.#response?.status || 0;
}
/**
* Returns the status text.
*
* @returns Status text.
*/
public get statusText(): string {
return this.#response?.statusText || '';
}
/**
* Returns the response.
*
* @returns Response.
*/
public get response(): ArrayBuffer | Blob | Document | object | string | null {
if (!this.#response) {
return '';
}
return this.#responseBody;
}
/**
* Get the response text.
*
* @throws {DOMException} If the response type is not text or empty.
* @returns The response text.
*/
public get responseText(): string {
if (this.responseType !== XMLHttpResponseTypeEnum.text && this.responseType !== '') {
throw new this[PropertySymbol.window].DOMException(
`Failed to read the 'responseText' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'text' (was '${this.responseType}').`,
DOMExceptionNameEnum.invalidStateError
);
}
// If response is complete, use the final response body
if (this.#responseBody !== null) {
return <string>this.#responseBody;
}
// If we're in loading state and have accumulated data, process it
if (
this.readyState === XMLHttpRequestReadyStateEnum.headersReceived ||
this.readyState === XMLHttpRequestReadyStateEnum.loading
) {
return <string>this.#accumulatedData.toString() ?? '';
}
return '';
}
/**
* Get the responseXML.
*
* @throws {DOMException} If the response type is not text or empty.
* @returns Response XML.
*/
public get responseXML(): Document | null {
if (this.responseType !== XMLHttpResponseTypeEnum.document && this.responseType !== '') {
throw new this[PropertySymbol.window].DOMException(
`Failed to read the 'responseXML' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'document' (was '${this.responseType}').`,
DOMExceptionNameEnum.invalidStateError
);
}
return this.responseType === '' ? null : <Document>this.#responseBody;
}
/**
* Returns the response URL.
*
* @returns Response URL.
*/
public get responseURL(): string {
return this.#response?.url || '';
}
/**
* Returns the ready state.
*
* @returns Ready state.
*/
public get readyState(): XMLHttpRequestReadyStateEnum {
return this.#readyState;
}
/**
* Set response type.
*
* @param type Response type.
* @throws {DOMException} If the state is not unsent or opened.
* @throws {DOMException} If the request is synchronous.
*/
public set responseType(type: XMLHttpResponseTypeEnum | '') {
// ResponseType can only be set when the state is unsent or opened.
if (
this.readyState !== XMLHttpRequestReadyStateEnum.opened &&
this.readyState !== XMLHttpRequestReadyStateEnum.unsent
) {
throw new this[PropertySymbol.window].DOMException(
`Failed to set the 'responseType' property on 'XMLHttpRequest': The object's state must be OPENED or UNSENT.`,
DOMExceptionNameEnum.invalidStateError
);
}
// Sync requests can only have empty string or 'text' as response type.
if (!this.#async) {
throw new this[PropertySymbol.window].DOMException(
`Failed to set the 'responseType' property on 'XMLHttpRequest': The response type cannot be changed for synchronous requests made from a document.`,
DOMExceptionNameEnum.invalidStateError
);
}
this.#responseType = type;
}
/**
* Get response Type.
*
* @returns Response type.
*/
public get responseType(): XMLHttpResponseTypeEnum | '' {
return this.#responseType;
}
/**
* Opens the connection.
*
* @param method Connection method (eg GET, POST).
* @param url URL for the connection.
* @param [async=true] Asynchronous connection.
* @param [user] Username for basic authentication (optional).
* @param [password] Password for basic authentication (optional).
*/
public open(method: string, url: string, async = true, user?: string, password?: string): void {
const window = this[PropertySymbol.window];
if (!async && !!this.responseType && this.responseType !== XMLHttpResponseTypeEnum.text) {
throw new window.DOMException(
`Failed to execute 'open' on 'XMLHttpRequest': Synchronous requests from a document must not set a response type.`,
DOMExceptionNameEnum.invalidAccessError
);
}
const headers = new this[PropertySymbol.window].Headers();
if (user) {
const authBuffer = Buffer.from(`${user}:${password || ''}`);
headers.set('Authorization', 'Basic ' + authBuffer.toString('base64'));
}
this.#async = async;
this.#aborted = false;
this.#response = null;
this.#responseBody = null;
this.#accumulatedData = Buffer.from([]);
this.#abortController = new window.AbortController();
this.#request = new window.Request(url, {
method,
headers,
signal: this.#abortController.signal,
credentials: this.withCredentials ? 'include' : 'same-origin'
});
this.#readyState = XMLHttpRequestReadyStateEnum.opened;
}
/**
* Sets a header for the request.
*
* @param name Header name.
* @param value Header value.
* @returns Header added.
*/
public setRequestHeader(name: string, value: string): boolean {
if (this.readyState !== XMLHttpRequestReadyStateEnum.opened) {
throw new this[PropertySymbol.window].DOMException(
`Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED.`,
DOMExceptionNameEnum.invalidStateError
);
}
// TODO: Use FetchRequestHeaderUtility.removeForbiddenHeaders() instead.
if (FetchRequestHeaderUtility.isHeaderForbidden(name)) {
return false;
}
this.#request!.headers.set(name, value);
return true;
}
/**
* Gets a header from the server response.
*
* @param header header Name of header to get.
* @returns string Text of the header or null if it doesn't exist.
*/
public getResponseHeader(header: string): string | null {
return this.#response?.headers.get(header) ?? null;
}
/**
* Gets all the response headers.
*
* @returns A string with all response headers separated by CR+LF.
*/
public getAllResponseHeaders(): string {
if (!this.#response) {
return '';
}
const result = [];
for (const [name, value] of this.#response?.headers) {
const lowerName = name.toLowerCase();
if (lowerName !== 'set-cookie' && lowerName !== 'set-cookie2') {
result.push(`${name}: ${value}`);
}
}
return result.join('\r\n');
}
/**
* Sends the request to the server.
*
* @param body Optional data to send as request body.
*/
public send(body?: Document | TRequestBody): void {
const window = this[PropertySymbol.window];
if (this.readyState != XMLHttpRequestReadyStateEnum.opened) {
throw new this[PropertySymbol.window].DOMException(
`Failed to execute 'send' on 'XMLHttpRequest': Connection must be opened before send() is called.`,
DOMExceptionNameEnum.invalidStateError
);
}
// When body is a Document, serialize it to a string.
if (
typeof body === 'object' &&
body !== null &&
(<Document>body)[PropertySymbol.nodeType] === NodeTypeEnum.documentNode
) {
body = new window.XMLSerializer().serializeToString(<Document>body);
}
if (this.#async) {
this.#sendAsync(<TRequestBody>body).catch((error) => {
throw error;
});
} else {
this.#sendSync(<TRequestBody>body);
}
}
/**
* Aborts a request.
*/
public abort(): void {
if (this.#aborted) {
return;
}
this.#aborted = true;
this.#abortController!.abort();
}
/**
* Overrides the MIME type returned by the server.
* This method must be called before send().
*
* @param mimeType The MIME type to use instead of the one specified by the server.
* @throws {DOMException} If the request state is LOADING or DONE.
*/
public overrideMimeType(mimeType: string): void {
if (
this.readyState === XMLHttpRequestReadyStateEnum.loading ||
this.readyState === XMLHttpRequestReadyStateEnum.done
) {
throw new this[PropertySymbol.window].DOMException(
`Failed to execute 'overrideMimeType' on 'XMLHttpRequest': MIME type cannot be overridden when the request state is LOADING or DONE.`,
DOMExceptionNameEnum.invalidStateError
);
}
this.#overriddenMimeType = mimeType;
}
/**
* Sends the request to the server asynchronously.
*
* @param body Optional data to send as request body.
*/
async #sendAsync(body?: TRequestBody): Promise<void> {
const window = this[PropertySymbol.window];
const browserFrame = new WindowBrowserContext(window).getBrowserFrame();
if (!browserFrame) {
return;
}
const asyncTaskManager = browserFrame[PropertySymbol.asyncTaskManager];
const taskID = asyncTaskManager.startTask(() => this.abort());
this.#readyState = XMLHttpRequestReadyStateEnum.loading;
this.#dispatchEvent(new Event('readystatechange'));
this.#dispatchEvent(new Event('loadstart'));
if (body) {
this.#request = new window.Request(this.#request!.url, {
method: this.#request!.method,
headers: this.#request!.headers,
signal: this.#abortController!.signal,
credentials: this.#request!.credentials,
body
});
}
this.#abortController!.signal.addEventListener('abort', () => {
this.#aborted = true;
this.#readyState = XMLHttpRequestReadyStateEnum.unsent;
this.#dispatchEvent(new Event('abort'));
this.#dispatchEvent(new Event('loadend'));
this.#dispatchEvent(new Event('readystatechange'));
asyncTaskManager.endTask(taskID);
});
const onError = (error: Error): void => {
if (error instanceof DOMException && error.name === DOMExceptionNameEnum.abortError) {
if (this.#aborted) {
return;
}
this.#readyState = XMLHttpRequestReadyStateEnum.unsent;
this.#dispatchEvent(new Event('abort'));
} else {
this.#readyState = XMLHttpRequestReadyStateEnum.done;
this.#dispatchEvent(new ErrorEvent('error', { error, message: error.message }));
}
this.#dispatchEvent(new Event('loadend'));
this.#dispatchEvent(new Event('readystatechange'));
asyncTaskManager.endTask(taskID);
};
const fetch = new Fetch({
browserFrame: browserFrame,
window: window,
url: this.#request!.url,
init: this.#request!
});
try {
this.#response = await fetch.send();
} catch (error) {
onError(<Error>error);
return;
}
this.#readyState = XMLHttpRequestReadyStateEnum.headersReceived;
this.#dispatchEvent(new Event('readystatechange'));
const contentLength = this.#response.headers.get('Content-Length');
const contentLengthNumber =
contentLength !== null && !isNaN(Number(contentLength)) ? Number(contentLength) : null;
let loaded = 0;
if (this.#response.body) {
let eventError: Error;
try {
for await (const chunk of this.#response.body) {
const chunkBuffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
this.#accumulatedData = Buffer.concat([this.#accumulatedData, chunkBuffer]);
loaded += chunk.length;
// We need to re-throw the error as we don't want it to be caught by the try/catch.
try {
this.#dispatchEvent(
new ProgressEvent('progress', {
lengthComputable: contentLengthNumber !== null,
loaded,
total: contentLengthNumber !== null ? contentLengthNumber : 0
})
);
} catch (error) {
eventError = <Error>error;
throw error;
}
}
} catch (error) {
if (<Error>error === eventError!) {
throw error;
}
onError(<Error>error);
return;
}
}
this.#responseBody = XMLHttpRequestResponseDataParser.parse({
window: window,
responseType: this.#responseType,
data: this.#accumulatedData,
contentType:
this.#overriddenMimeType ||
this.#response.headers.get('Content-Type') ||
this.#request!.headers.get('Content-Type')
});
this.#readyState = XMLHttpRequestReadyStateEnum.done;
this.#accumulatedData = Buffer.from([]);
asyncTaskManager.endTask(taskID);
this.#dispatchEvent(new Event('readystatechange'));
this.#dispatchEvent(new Event('load'));
this.#dispatchEvent(new Event('loadend'));
}
/**
* Sends the request to the server synchronously.
*
* @param body Optional data to send as request body.
*/
#sendSync(body?: TRequestBody): void {
const window = this[PropertySymbol.window];
const browserFrame = new WindowBrowserContext(window).getBrowserFrame();
if (!browserFrame) {
return;
}
if (body) {
this.#request = new window.Request(this.#request!.url, {
method: this.#request!.method,
headers: this.#request!.headers,
signal: this.#abortController!.signal,
credentials: this.#request!.credentials,
body
});
}
this.#readyState = XMLHttpRequestReadyStateEnum.loading;
const fetch = new SyncFetch({
browserFrame,
window: window,
url: this.#request!.url,
init: this.#request!
});
try {
this.#response = fetch.send();
} catch (error) {
this.#readyState = XMLHttpRequestReadyStateEnum.done;
this.#dispatchEvent(
new ErrorEvent('error', { error: <Error>error, message: (<Error>error).message })
);
this.#dispatchEvent(new Event('loadend'));
this.#dispatchEvent(new Event('readystatechange'));
return;
}
this.#readyState = XMLHttpRequestReadyStateEnum.headersReceived;
this.#responseBody = XMLHttpRequestResponseDataParser.parse({
window: window,
responseType: this.#responseType,
data: this.#response.body,
contentType:
this.#overriddenMimeType ||
this.#response.headers.get('Content-Type') ||
this.#request!.headers.get('Content-Type')
});
this.#readyState = XMLHttpRequestReadyStateEnum.done;
this.#dispatchEvent(new Event('readystatechange'));
this.#dispatchEvent(new Event('load'));
this.#dispatchEvent(new Event('loadend'));
}
/**
* Dispatches an event with try/catch.
*
* This is necessary to prevent errors from setting XMLHttpRequest in an invalid state.
*
* @param event Event.
*/
#dispatchEvent(event: Event): void {
const browserFrame = new WindowBrowserContext(this[PropertySymbol.window]).getBrowserFrame();
if (!browserFrame?.page?.context?.browser?.settings?.errorCapture) {
return;
}
const settings = browserFrame.page.context.browser.settings;
const errorCapture = settings.errorCapture;
settings.errorCapture = BrowserErrorCaptureEnum.tryAndCatch;
this.dispatchEvent(event);
settings.errorCapture = errorCapture;
}
}