From 18b0bd6091292d0df73e91fc32c407fe5a53186d Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sat, 25 Jun 2022 22:51:47 +0800 Subject: [PATCH 01/40] #463@minor: Added partial support for XMLHttpRequest. --- .../src/exception/DOMExceptionNameEnum.ts | 4 +- .../happy-dom/src/location/RelativeURL.ts | 20 +- packages/happy-dom/src/window/Window.ts | 9 + .../src/xml-http-request/XMLHttpRequest.ts | 580 ++++++++++++++++++ .../XMLHttpRequestEventTarget.ts | 17 + .../xml-http-request/XMLHttpRequestUpload.ts | 87 +++ .../test/location/RelativeURL.test.ts | 2 +- .../xml-http-request/XMLHttpRequest.test.ts | 18 + 8 files changed, 733 insertions(+), 4 deletions(-) create mode 100644 packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts create mode 100644 packages/happy-dom/src/xml-http-request/XMLHttpRequestEventTarget.ts create mode 100644 packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts create mode 100644 packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts diff --git a/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts b/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts index aa051467d..6715aff7f 100644 --- a/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts +++ b/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts @@ -3,6 +3,8 @@ enum DOMExceptionNameEnum { indexSizeError = 'IndexSizeError', syntaxError = 'SyntaxError', hierarchyRequestError = 'HierarchyRequestError', - invalidCharacterError = 'InvalidCharacterError' + invalidCharacterError = 'InvalidCharacterError', + securityError = 'SecurityError', + networkError = 'NetworkError' } export default DOMExceptionNameEnum; diff --git a/packages/happy-dom/src/location/RelativeURL.ts b/packages/happy-dom/src/location/RelativeURL.ts index b60ff1c99..d308b2473 100644 --- a/packages/happy-dom/src/location/RelativeURL.ts +++ b/packages/happy-dom/src/location/RelativeURL.ts @@ -11,16 +11,32 @@ export default class RelativeURL { * @param url URL. */ public static getAbsoluteURL(location: Location, url: string): string { + // If the URL starts with '//' then it is a Protocol relative URL. + // Reference: https://url.spec.whatwg.org/#protocol-relative-urls. + // E.g. '//example.com/' needs to be converted to 'http://example.com/'. + if (url.startsWith('//')) { + return location.protocol + url; + } + // If the URL starts with '/' then it is a Path relative URL. + // E.g. '/example.com/' needs to be converted to 'http://example.com/'. if (url.startsWith('/')) { return location.origin + url; } - + // If the URL starts with 'https://' or 'http://' then it is a Absolute URL. + // E.g. 'https://example.com' needs to be converted to 'https://example.com/'. + // E.g. 'http://example.com' needs to be converted to 'http://example.com/'. if (!url.startsWith('https://') && !url.startsWith('http://')) { let pathname = location.pathname; if (pathname.endsWith('/')) { pathname = pathname.slice(0, -1); } - return location.origin + pathname + '/' + url; + + return ( + location.origin + + (/(.*)\/(.*)[^\/]$/.test(pathname) ? pathname.match(/(.*)\/(.*)[^\/]$/)[1] : pathname) + + '/' + + url + ); } return url; diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index a2d63cd4a..7357cd4fd 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -93,6 +93,9 @@ import * as PerfHooks from 'perf_hooks'; import VM from 'vm'; import { Buffer } from 'buffer'; import { atob, btoa } from './WindowBase64'; +import XMLHttpRequest from '../xml-http-request/XMLHttpRequest'; +import XMLHttpRequestUpload from '../xml-http-request/XMLHttpRequestUpload'; +import { XMLHttpRequestEventTarget } from '../xml-http-request/XMLHttpRequestEventTarget'; /** * Browser window. @@ -196,6 +199,11 @@ export default class Window extends EventTarget implements IWindow { } = Response; public readonly DOMRect: typeof DOMRect; + // XMLHttpRequest + public XMLHttpRequest = XMLHttpRequest; + public XMLHttpRequestUpload = XMLHttpRequestUpload; + public XMLHttpRequestEventTarget = XMLHttpRequestEventTarget; + // Events public onload: (event: Event) => void = null; public onerror: (event: ErrorEvent) => void = null; @@ -305,6 +313,7 @@ export default class Window extends EventTarget implements IWindow { this.dispatchEvent(new Event('load')); }); + XMLHttpRequest._defaultView = this; DOMParser._ownerDocument = this.document; FileReader._ownerDocument = this.document; Image.ownerDocument = this.document; diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts new file mode 100644 index 000000000..510f9ea9b --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -0,0 +1,580 @@ +import * as http from 'http'; +import { + Agent as HttpAgent, + ClientRequest, + IncomingMessage, + RequestOptions as RequestOptionsHttp +} from 'http'; +import * as https from 'https'; +import { Agent as HttpsAgent } from 'https'; +import ProgressEvent from '../event/events/ProgressEvent'; +import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum'; +import { ProgressEventListener, XMLHttpRequestEventTarget } from './XMLHttpRequestEventTarget'; +import XMLHttpRequestUpload from './XMLHttpRequestUpload'; +import DOMException from '../exception/DOMException'; +import IWindow from '../window/IWindow'; +import URL from '../location/URL'; +import RelativeURL from '../location/RelativeURL'; + +interface IXMLHttpRequestOptions { + anon?: boolean; +} + +/** + * + */ +export default class XMLHttpRequest extends XMLHttpRequestEventTarget { + public static readonly UNSENT = 0; + public static readonly OPENED = 1; + public static readonly HEADERS_RECEIVED = 2; + public static readonly LOADING = 3; + public static readonly DONE = 4; + + public static _defaultView: IWindow; + + public onreadystatechange: ProgressEventListener | null = null; + public readyState: number = XMLHttpRequest.UNSENT; + + public response: string | ArrayBuffer | Buffer | object | null = null; + public responseText = ''; + public responseType = ''; + public status = 0; // TODO: UNSENT? + public statusText = ''; + public timeout = 0; + public upload = new XMLHttpRequestUpload(); + public responseUrl = ''; + public withCredentials = false; + // Todo: need rewrite. + public nodejsHttpAgent: HttpAgent = http.globalAgent; + public nodejsHttpsAgent: HttpsAgent = https.globalAgent; + + private readonly anonymous: boolean; + private method: string | null = null; + private url: URL | null = null; + private sync = false; + private headers: { [header: string]: string } = {}; + private loweredHeaders: { [lowercaseHeader: string]: string } = {}; + private mimeOverride: string | null = null; // TODO: is type right? + private _request: ClientRequest | null = null; + private _response: IncomingMessage | null = null; + private responseParts: Buffer[] | null = null; + private responseHeaders: { [lowercaseHeader: string]: string } | null = null; + private loadedBytes = 0; + private totalBytes = 0; + private lengthComputable = false; + + private restrictedMethods = { CONNECT: true, TRACE: true, TRACK: true }; + private restrictedHeaders = { + 'accept-charset': true, + 'accept-encoding': true, + 'access-control-request-headers': true, + 'access-control-request-method': true, + connection: true, + 'content-length': true, + cookie: true, + date: true, + dnt: true, + expect: true, + host: true, + 'keep-alive': true, + origin: true, + referer: true, + te: true, + trailer: true, + 'transfer-encoding': true, + upgrade: true, + 'user-agent': true, + via: true + }; + private privateHeaders = { 'set-cookie': true }; + private userAgent = XMLHttpRequest._defaultView.navigator.userAgent; + + /** + * + * @param options + */ + constructor(options: IXMLHttpRequestOptions = {}) { + super(); + this.anonymous = options.anon || false; + } + + /** + * + * @param method + * @param url + * @param async + * @param user + * @param password + */ + public open(method: string, url: string, async = true, user?: string, password?: string): void { + method = method.toUpperCase(); + if (this.restrictedMethods[method]) { + throw new DOMException( + `HTTP method ${method} is not allowed in XHR`, + DOMExceptionNameEnum.securityError + ); + } + const xhrUrl = new XMLHttpRequest._defaultView.URL( + RelativeURL.getAbsoluteURL(XMLHttpRequest._defaultView.location, url) + ); + xhrUrl.username = user ? user : xhrUrl.username; + xhrUrl.password = password ? password : xhrUrl.password; + + if ( + this.readyState === XMLHttpRequest.HEADERS_RECEIVED || + this.readyState === XMLHttpRequest.LOADING + ) { + // TODO: terminate abort(), terminate send() + } + + this.method = method; + this.url = xhrUrl; + this.sync = !async; + this.headers = {}; + this.loweredHeaders = {}; + this.mimeOverride = null; + this.setReadyState(XMLHttpRequest.OPENED); + this._request = null; + this._response = null; + this.status = 0; + this.statusText = ''; + this.responseParts = []; + this.responseHeaders = null; + this.loadedBytes = 0; + this.totalBytes = 0; + this.lengthComputable = false; + } + + /** + * + * @param name + * @param value + */ + public setRequestHeader(name: string, value: unknown): void { + if (this.readyState !== XMLHttpRequest.OPENED) { + throw new DOMException( + 'XHR readyState must be OPENED', + DOMExceptionNameEnum.invalidStateError + ); + } + + const loweredName = name.toLowerCase(); + if ( + this.restrictedHeaders[loweredName] || + /^sec-/.test(loweredName) || + /^proxy-/.test(loweredName) + ) { + // eslint-disable-next-line no-console + console.warn(`Refused to set unsafe header "${name}"`); + return; + } + + const headerBody = value.toString(); + if (this.loweredHeaders[loweredName] != null) { + name = this.loweredHeaders[loweredName]; + this.headers[name] = `${this.headers[name]}, ${headerBody}`; + } else { + this.loweredHeaders[loweredName] = name; + this.headers[name] = headerBody; + } + } + + /** + * + * @param data + */ + public send(data?: string | Buffer | ArrayBuffer | ArrayBufferView): void { + if (this.readyState !== XMLHttpRequest.OPENED) { + throw new DOMException( + 'XHR readyState must be OPENED', + DOMExceptionNameEnum.invalidStateError + ); + } + if (this._request) { + throw new DOMException('send() already called', DOMExceptionNameEnum.invalidStateError); + } + switch (this.url.protocol) { + case 'file:': + return this.sendFile(data); + case 'http:': + case 'https:': + return this.sendHttp(data); + default: + throw new DOMException( + `Unsupported protocol ${this.url.protocol}`, + DOMExceptionNameEnum.networkError + ); + } + } + + /** + * + */ + public abort(): void { + if (this._request == null) { + return; + } + + this._request.destroy(); + this.setError(); + + this.dispatchProgress('abort'); + this.dispatchProgress('loadend'); + } + + /** + * + * @param name + */ + public getResponseHeader(name: string): string { + if (this.responseHeaders == null || name == null) { + return null; + } + const loweredName = name.toLowerCase(); + return this.responseHeaders.hasOwnProperty(loweredName) + ? this.responseHeaders[name.toLowerCase()] + : null; + } + + /** + * + */ + public getAllResponseHeaders(): string { + if (this.responseHeaders == null) { + return ''; + } + return Object.keys(this.responseHeaders) + .map((key) => `${key}: ${this.responseHeaders[key]}`) + .join('\r\n'); + } + + /** + * + * @param mimeType + */ + public overrideMimeType(mimeType: string): void { + if (this.readyState === XMLHttpRequest.LOADING || this.readyState === XMLHttpRequest.DONE) { + throw new DOMException( + 'overrideMimeType() not allowed in LOADING or DONE', + DOMExceptionNameEnum.invalidStateError + ); + } + this.mimeOverride = mimeType.toLowerCase(); + } + + /** + * + * @param readyState + */ + private setReadyState(readyState: number): void { + this.readyState = readyState; + this.dispatchEvent(new ProgressEvent('readystatechange')); + } + + /** + * + * @param _data + */ + private sendFile(_data: unknown): void { + // TODO + throw new Error('Protocol file: not implemented'); + } + + /** + * + * @param data + */ + private sendHttp(data?: string | Buffer | ArrayBuffer | ArrayBufferView): void { + if (this.sync) { + throw new Error('Synchronous XHR processing not implemented'); + } + if (data && (this.method === 'GET' || this.method === 'HEAD')) { + // eslint-disable-next-line no-console + console.warn(`Discarding entity body for ${this.method} requests`); + data = null; + } else { + data = data || ''; + } + + this.upload.setData(data); + this.finalizeHeaders(); + this.sendHxxpRequest(); + } + + /** + * + */ + private sendHxxpRequest(): void { + if (this.withCredentials) { + // Todo: need to verify same origin. + this.headers.cookie = XMLHttpRequest._defaultView.document.cookie; + } + + const [hxxp, agent] = + this.url.protocol === 'http:' ? [http, this.nodejsHttpAgent] : [https, this.nodejsHttpsAgent]; + const requestMethod: (options: RequestOptionsHttp) => ClientRequest = hxxp.request.bind(hxxp); + const request = requestMethod({ + hostname: this.url.hostname, + port: +this.url.port, + path: this.url.pathname, + auth: `${this.url.username || ''}:${this.url.password || ''}`, + method: this.method, + headers: this.headers, + agent + }); + this._request = request; + + if (this.timeout) { + request.setTimeout(this.timeout, () => this.onHttpTimeout(request)); + } + request.on('response', (response) => this.onHttpResponse(request, response)); + request.on('error', (error) => this.onHttpRequestError(request, error)); + this.upload.startUpload(request); + + if (this._request === request) { + this.dispatchProgress('loadstart'); + } + } + + /** + * + */ + private finalizeHeaders(): void { + this.headers = { + ...this.headers, + Connection: 'keep-alive', + Host: this.url.host, + 'User-Agent': this.userAgent, + ...(this.anonymous ? { Referer: 'about:blank' } : {}) + }; + this.upload.finalizeHeaders(this.headers, this.loweredHeaders); + } + + /** + * + * @param request + * @param response + */ + private onHttpResponse(request: ClientRequest, response: IncomingMessage): void { + if (this._request !== request) { + return; + } + + if (this.withCredentials && response.headers['set-cookie']) { + XMLHttpRequest._defaultView.document.cookie = response.headers['set-cookie'].join('; '); + } + + if ([301, 302, 303, 307, 308].indexOf(response.statusCode) >= 0) { + this.url = new XMLHttpRequest._defaultView.URL(response.headers.location); + this.method = 'GET'; + if (this.loweredHeaders['content-type']) { + delete this.headers[this.loweredHeaders['content-type']]; + delete this.loweredHeaders['content-type']; + } + if (this.headers['Content-Type'] != null) { + delete this.headers['Content-Type']; + } + delete this.headers['Content-Length']; + + this.upload.reset(); + this.finalizeHeaders(); + this.sendHxxpRequest(); + return; + } + + this._response = response; + this._response.on('data', (data) => this.onHttpResponseData(response, data)); + this._response.on('end', () => this.onHttpResponseEnd(response)); + this._response.on('close', () => this.onHttpResponseClose(response)); + + this.responseUrl = this.url.href.split('#')[0]; + this.status = response.statusCode; + this.statusText = http.STATUS_CODES[this.status]; + this.parseResponseHeaders(response); + + const lengthString = this.responseHeaders['content-length'] || ''; + this.totalBytes = +lengthString; + this.lengthComputable = !!lengthString; + + this.setReadyState(XMLHttpRequest.HEADERS_RECEIVED); + } + + /** + * + * @param response + * @param data + */ + private onHttpResponseData(response: IncomingMessage, data: string | Buffer): void { + if (this._response !== response) { + return; + } + + this.responseParts.push(new Buffer(data)); + this.loadedBytes += data.length; + + if (this.readyState !== XMLHttpRequest.LOADING) { + this.setReadyState(XMLHttpRequest.LOADING); + } + + this.dispatchProgress('progress'); + } + + /** + * + * @param response + */ + private onHttpResponseEnd(response: IncomingMessage): void { + if (this._response !== response) { + return; + } + + this.parseResponse(); + this._request = null; + this._response = null; + this.setReadyState(XMLHttpRequest.DONE); + + this.dispatchProgress('load'); + this.dispatchProgress('loadend'); + } + + /** + * + * @param response + */ + private onHttpResponseClose(response: IncomingMessage): void { + if (this._response !== response) { + return; + } + + const request = this._request; + this.setError(); + request.destroy(); + this.setReadyState(XMLHttpRequest.DONE); + + this.dispatchProgress('error'); + this.dispatchProgress('loadend'); + } + + /** + * + * @param request + */ + private onHttpTimeout(request: ClientRequest): void { + if (this._request !== request) { + return; + } + + this.setError(); + request.destroy(); + this.setReadyState(XMLHttpRequest.DONE); + + this.dispatchProgress('timeout'); + this.dispatchProgress('loadend'); + } + + /** + * + * @param request + * @param _error + */ + private onHttpRequestError(request: ClientRequest, _error: Error): void { + if (this._request !== request) { + return; + } + + this.setError(); + request.destroy(); + this.setReadyState(XMLHttpRequest.DONE); + + this.dispatchProgress('error'); + this.dispatchProgress('loadend'); + } + + /** + * + * @param eventType + */ + private dispatchProgress(eventType: string): void { + const event = new ProgressEvent(eventType, { + lengthComputable: this.lengthComputable, + loaded: this.loadedBytes, + total: this.totalBytes + }); + this.dispatchEvent(event); + } + + /** + * + */ + private setError(): void { + this._request = null; + this._response = null; + this.responseHeaders = null; + this.responseParts = null; + } + + /** + * + * @param response + */ + private parseResponseHeaders(response: IncomingMessage): void { + this.responseHeaders = {}; + for (const name in response.headers) { + const loweredName = name.toLowerCase(); + if (this.privateHeaders[loweredName]) { + continue; + } + this.responseHeaders[loweredName] = response.headers[name]; + } + if (this.mimeOverride != null) { + this.responseHeaders['content-type'] = this.mimeOverride; + } + } + + /** + * + */ + private parseResponse(): void { + const buffer = Buffer.concat(this.responseParts); + this.responseParts = null; + + switch (this.responseType) { + case 'json': + this.responseText = null; + try { + this.response = JSON.parse(buffer.toString('utf-8')); + } catch { + this.response = null; + } + return; + case 'buffer': + this.responseText = null; + this.response = buffer; + return; + case 'arraybuffer': + this.responseText = null; + const arrayBuffer = new ArrayBuffer(buffer.length); + const view = new Uint8Array(arrayBuffer); + for (let i = 0; i < buffer.length; i++) { + view[i] = buffer[i]; + } + this.response = arrayBuffer; + return; + case 'text': + default: + try { + this.responseText = buffer.toString(this.parseResponseEncoding()); + } catch { + this.responseText = buffer.toString('binary'); + } + this.response = this.responseText; + } + } + + /** + * + */ + private parseResponseEncoding(): string { + return /;\s*charset=(.*)$/.exec(this.responseHeaders['content-type'] || '')[1] || 'utf-8'; + } +} diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestEventTarget.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestEventTarget.ts new file mode 100644 index 000000000..c04e4f4b3 --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestEventTarget.ts @@ -0,0 +1,17 @@ +import ProgressEvent from '../event/events/ProgressEvent'; +import EventTarget from '../event/EventTarget'; + +export type ProgressEventListener = (event: ProgressEvent) => void; + +/** + * References: https://xhr.spec.whatwg.org/#xmlhttprequesteventtarget. + */ +export class XMLHttpRequestEventTarget extends EventTarget { + public onloadstart: ProgressEventListener | null; + public onprogress: ProgressEventListener | null; + public onabort: ProgressEventListener | null; + public onerror: ProgressEventListener | null; + public onload: ProgressEventListener | null; + public ontimeout: ProgressEventListener | null; + public onloadend: ProgressEventListener | null; +} diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts new file mode 100644 index 000000000..62ccff252 --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts @@ -0,0 +1,87 @@ +import { XMLHttpRequestEventTarget } from './XMLHttpRequestEventTarget'; +import { ClientRequest } from 'http'; + +/** + * + */ +export default class XMLHttpRequestUpload extends XMLHttpRequestEventTarget { + private _contentType: string | null = null; + private _body = null; + + /** + * + */ + constructor() { + super(); + this.reset(); + } + + /** + * + */ + public reset(): void { + this._contentType = null; + this._body = null; + } + + /** + * + * @param data + */ + public setData(data?: string | Buffer | ArrayBuffer | ArrayBufferView): void { + if (data == null) { + return; + } + + if (typeof data === 'string') { + if (data.length !== 0) { + this._contentType = 'text/plain;charset=UTF-8'; + } + this._body = new Buffer(data, 'utf-8'); + } else if (Buffer.isBuffer(data)) { + this._body = data; + } else if (data instanceof ArrayBuffer) { + const body = new Buffer(data.byteLength); + const view = new Uint8Array(data); + for (let i = 0; i < data.byteLength; i++) { + body[i] = view[i]; + } + this._body = body; + } else if (data.buffer && data.buffer instanceof ArrayBuffer) { + const body = new Buffer(data.byteLength); + const offset = data.byteOffset; + const view = new Uint8Array(data.buffer); + for (let i = 0; i < data.byteLength; i++) { + body[i] = view[i + offset]; + } + this._body = body; + } else { + throw new Error(`Unsupported send() data ${data}`); + } + } + + /** + * + * @param headers + * @param loweredHeaders + */ + public finalizeHeaders(headers: object, loweredHeaders: object): void { + if (this._contentType && !loweredHeaders['content-type']) { + headers['Content-Type'] = this._contentType; + } + if (this._body) { + headers['Content-Length'] = this._body.length.toString(); + } + } + + /** + * + * @param request + */ + public startUpload(request: ClientRequest): void { + if (this._body) { + request.write(this._body); + } + request.end(); + } +} diff --git a/packages/happy-dom/test/location/RelativeURL.test.ts b/packages/happy-dom/test/location/RelativeURL.test.ts index e47013779..29ec0a990 100644 --- a/packages/happy-dom/test/location/RelativeURL.test.ts +++ b/packages/happy-dom/test/location/RelativeURL.test.ts @@ -12,7 +12,7 @@ describe('RelativeURL', () => { it('Returns absolute URL when location is "https://localhost:8080/base/" and URL is "path/to/resource/".', () => { location.href = 'https://localhost:8080/base/'; expect(RelativeURL.getAbsoluteURL(location, 'path/to/resource/')).toBe( - 'https://localhost:8080/base/path/to/resource/' + 'https://localhost:8080/path/to/resource/' ); }); diff --git a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts new file mode 100644 index 000000000..a8503612e --- /dev/null +++ b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts @@ -0,0 +1,18 @@ +import { XMLHttpRequest } from '../../lib/xml-http-request/xml-http-request'; + +describe('XMLHttpRequest', () => { + let xhr: XMLHttpRequest; + beforeEach(() => { + xhr = new XMLHttpRequest(); + }); + + it('XMLHttpRequest()', function () { + xhr.open('GET', 'http://localhost:8080/path/to/resource/', false); + xhr.addEventListener('load', () => { + expect(this.status).toBe(200); + expect(this.responseText).toBe('test'); + expect(this.response).toBe('test'); + }); + xhr.send(); + }); +}); From 3e676057275d67fa4c944d5d68e204015480522b Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 26 Jun 2022 11:54:43 +0800 Subject: [PATCH 02/40] #514@patch: Fixes method getAbsoluteURL. --- packages/happy-dom/src/location/RelativeURL.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/happy-dom/src/location/RelativeURL.ts b/packages/happy-dom/src/location/RelativeURL.ts index d308b2473..1ae92557d 100644 --- a/packages/happy-dom/src/location/RelativeURL.ts +++ b/packages/happy-dom/src/location/RelativeURL.ts @@ -33,7 +33,7 @@ export default class RelativeURL { return ( location.origin + - (/(.*)\/(.*)[^\/]$/.test(pathname) ? pathname.match(/(.*)\/(.*)[^\/]$/)[1] : pathname) + + (/(.*)\/.*/.test(pathname) ? pathname.match(/(.*)\/.*/)[1] : '') + '/' + url ); From 30b48604f5a7fb171ddf7ee1d04ade01a20cb3a0 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 26 Jun 2022 13:18:46 +0800 Subject: [PATCH 03/40] #463@minor: Continue added partial support for XMLHttpRequest. --- .../src/xml-http-request/XMLHttpRequest.ts | 15 ++++++++++----- .../src/xml-http-request/XMLHttpRequestUpload.ts | 6 +++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 510f9ea9b..8bdadd0ef 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -57,6 +57,8 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { private mimeOverride: string | null = null; // TODO: is type right? private _request: ClientRequest | null = null; private _response: IncomingMessage | null = null; + // @ts-ignore + private _error: Error | null = null; private responseParts: Buffer[] | null = null; private responseHeaders: { [lowercaseHeader: string]: string } | null = null; private loadedBytes = 0; @@ -72,6 +74,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { connection: true, 'content-length': true, cookie: true, + cookie2: true, date: true, dnt: true, expect: true, @@ -86,7 +89,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { 'user-agent': true, via: true }; - private privateHeaders = { 'set-cookie': true }; + private privateHeaders = { 'set-cookie': true, 'set-cookie2': true }; private userAgent = XMLHttpRequest._defaultView.navigator.userAgent; /** @@ -409,7 +412,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { return; } - this.responseParts.push(new Buffer(data)); + this.responseParts.push(Buffer.from(data)); this.loadedBytes += data.length; if (this.readyState !== XMLHttpRequest.LOADING) { @@ -475,9 +478,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { /** * * @param request - * @param _error + * @param error */ - private onHttpRequestError(request: ClientRequest, _error: Error): void { + private onHttpRequestError(request: ClientRequest, error: Error): void { + this._error = error; if (this._request !== request) { return; } @@ -575,6 +579,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * */ private parseResponseEncoding(): string { - return /;\s*charset=(.*)$/.exec(this.responseHeaders['content-type'] || '')[1] || 'utf-8'; + const charset = /;\s*charset=(.*)$/.exec(this.responseHeaders['content-type'] || ''); + return Array.isArray(charset) ? charset[1] : 'utf-8'; } } diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts index 62ccff252..1e418792d 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts @@ -37,18 +37,18 @@ export default class XMLHttpRequestUpload extends XMLHttpRequestEventTarget { if (data.length !== 0) { this._contentType = 'text/plain;charset=UTF-8'; } - this._body = new Buffer(data, 'utf-8'); + this._body = Buffer.from(data, 'utf-8'); } else if (Buffer.isBuffer(data)) { this._body = data; } else if (data instanceof ArrayBuffer) { - const body = new Buffer(data.byteLength); + const body = Buffer.alloc(data.byteLength); const view = new Uint8Array(data); for (let i = 0; i < data.byteLength; i++) { body[i] = view[i]; } this._body = body; } else if (data.buffer && data.buffer instanceof ArrayBuffer) { - const body = new Buffer(data.byteLength); + const body = Buffer.alloc(data.byteLength); const offset = data.byteOffset; const view = new Uint8Array(data.buffer); for (let i = 0; i < data.byteLength; i++) { From 9d9b455d9ca91546cd1f39a8483e575c1bb0abc0 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 26 Jun 2022 20:36:28 +0800 Subject: [PATCH 04/40] #463@minor: Continue added partial support for XMLHttpRequest. --- .../src/xml-http-request/XMLHttpRequest.ts | 104 +++++++++++------- 1 file changed, 67 insertions(+), 37 deletions(-) diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 8bdadd0ef..b8f7724c3 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -21,7 +21,7 @@ interface IXMLHttpRequestOptions { } /** - * + * References: https://github.com/souldreamer/xhr2-cookies. */ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { public static readonly UNSENT = 0; @@ -38,13 +38,13 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { public response: string | ArrayBuffer | Buffer | object | null = null; public responseText = ''; public responseType = ''; - public status = 0; // TODO: UNSENT? + public status = 0; public statusText = ''; public timeout = 0; public upload = new XMLHttpRequestUpload(); public responseUrl = ''; public withCredentials = false; - // Todo: need rewrite. + // TODO: another way to set proxy? public nodejsHttpAgent: HttpAgent = http.globalAgent; public nodejsHttpsAgent: HttpsAgent = https.globalAgent; @@ -90,9 +90,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { via: true }; private privateHeaders = { 'set-cookie': true, 'set-cookie2': true }; - private userAgent = XMLHttpRequest._defaultView.navigator.userAgent; /** + * News a request. * * @param options */ @@ -102,12 +102,13 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Initializes a newly-created request, or re-initializes an existing one. * - * @param method - * @param url - * @param async - * @param user - * @param password + * @param method The HTTP request method to use. + * @param url The URL to request. + * @param async Whether the request is synchronous or asynchronous. + * @param user The username to use for authentication purposes. + * @param password The password to use for authentication purposes. */ public open(method: string, url: string, async = true, user?: string, password?: string): void { method = method.toUpperCase(); @@ -117,9 +118,15 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { DOMExceptionNameEnum.securityError ); } + // If _defaultView is not defined, then we can't set the URL. + if (!XMLHttpRequest._defaultView) { + throw new Error('need set defaultView'); + } + // Get and Parse the URL relative to the given Location object. const xhrUrl = new XMLHttpRequest._defaultView.URL( RelativeURL.getAbsoluteURL(XMLHttpRequest._defaultView.location, url) ); + // Set username and password if given. xhrUrl.username = user ? user : xhrUrl.username; xhrUrl.password = password ? password : xhrUrl.password; @@ -149,9 +156,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Sets the value of an HTTP request header. * - * @param name - * @param value + * @param name The name of the header whose value is to be set. + * @param value The value to set as the body of the header. */ public setRequestHeader(name: string, value: unknown): void { if (this.readyState !== XMLHttpRequest.OPENED) { @@ -167,8 +175,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { /^sec-/.test(loweredName) || /^proxy-/.test(loweredName) ) { - // eslint-disable-next-line no-console - console.warn(`Refused to set unsafe header "${name}"`); + XMLHttpRequest._defaultView.console.warn(`Refused to set unsafe header "${name}"`); return; } @@ -183,8 +190,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Sends the request. If the request is asynchronous (which is the default), this method returns as soon as the request is sent. * - * @param data + * @param data The data to send with the request. */ public send(data?: string | Buffer | ArrayBuffer | ArrayBufferView): void { if (this.readyState !== XMLHttpRequest.OPENED) { @@ -211,7 +219,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** - * + * Aborts the request if it has already been sent. */ public abort(): void { if (this._request == null) { @@ -226,8 +234,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Returns the string containing the text of the specified header, or null if either the response has not yet been received or the header doesn't exist in the response. * - * @param name + * @param name The name of the header. */ public getResponseHeader(name: string): string { if (this.responseHeaders == null || name == null) { @@ -240,6 +249,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Returns all the response headers, separated by CRLF, as a string, or null if no response has been received. * */ public getAllResponseHeaders(): string { @@ -252,8 +262,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Overrides the MIME type returned by the server. * - * @param mimeType + * @param mimeType The MIME type to use. */ public overrideMimeType(mimeType: string): void { if (this.readyState === XMLHttpRequest.LOADING || this.readyState === XMLHttpRequest.DONE) { @@ -266,8 +277,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Sets the value of the ReadyState. * - * @param readyState + * @param readyState The new value of the ReadyState. */ private setReadyState(readyState: number): void { this.readyState = readyState; @@ -275,25 +287,28 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Send request with file. * - * @param _data + * @param _data File body to send. */ private sendFile(_data: unknown): void { - // TODO + // TODO: sendFile() not implemented. throw new Error('Protocol file: not implemented'); } /** + * Send request with http. * - * @param data + * @param data Data to send. */ private sendHttp(data?: string | Buffer | ArrayBuffer | ArrayBufferView): void { if (this.sync) { throw new Error('Synchronous XHR processing not implemented'); } if (data && (this.method === 'GET' || this.method === 'HEAD')) { - // eslint-disable-next-line no-console - console.warn(`Discarding entity body for ${this.method} requests`); + XMLHttpRequest._defaultView.console.warn( + `Discarding entity body for ${this.method} requests` + ); data = null; } else { data = data || ''; @@ -305,12 +320,15 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * SendHxxpRequest sends the actual request. * */ private sendHxxpRequest(): void { if (this.withCredentials) { - // Todo: need to verify same origin. - this.headers.cookie = XMLHttpRequest._defaultView.document.cookie; + // Set cookie if URL is same-origin. + if (XMLHttpRequest._defaultView.location.origin === this.url.origin) { + this.headers.cookie = XMLHttpRequest._defaultView.document.cookie; + } } const [hxxp, agent] = @@ -340,6 +358,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Finalize headers. * */ private finalizeHeaders(): void { @@ -347,16 +366,17 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ...this.headers, Connection: 'keep-alive', Host: this.url.host, - 'User-Agent': this.userAgent, + 'User-Agent': XMLHttpRequest._defaultView.navigator.userAgent, ...(this.anonymous ? { Referer: 'about:blank' } : {}) }; this.upload.finalizeHeaders(this.headers, this.loweredHeaders); } /** + * OnHttpResponse handles the response. * - * @param request - * @param response + * @param request The request. + * @param response The response. */ private onHttpResponse(request: ClientRequest, response: IncomingMessage): void { if (this._request !== request) { @@ -403,9 +423,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * OnHttpResponseData handles the response data. * - * @param response - * @param data + * @param response The response. + * @param data The data. */ private onHttpResponseData(response: IncomingMessage, data: string | Buffer): void { if (this._response !== response) { @@ -423,8 +444,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * OnHttpResponseEnd handles the response end. * - * @param response + * @param response The response. */ private onHttpResponseEnd(response: IncomingMessage): void { if (this._response !== response) { @@ -441,8 +463,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * OnHttpResponseClose handles the response close. * - * @param response + * @param response The response. */ private onHttpResponseClose(response: IncomingMessage): void { if (this._response !== response) { @@ -459,8 +482,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * OnHttpRequestError handles the request error. * - * @param request + * @param request The request. */ private onHttpTimeout(request: ClientRequest): void { if (this._request !== request) { @@ -476,9 +500,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * OnHttpRequestError handles the request error. * - * @param request - * @param error + * @param request The request. + * @param error The error. */ private onHttpRequestError(request: ClientRequest, error: Error): void { this._error = error; @@ -495,8 +520,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Dispatches the progress event. * - * @param eventType + * @param eventType The event type. */ private dispatchProgress(eventType: string): void { const event = new ProgressEvent(eventType, { @@ -508,6 +534,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Sets the error. * */ private setError(): void { @@ -518,8 +545,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Parses the response headers. * - * @param response + * @param response The response. */ private parseResponseHeaders(response: IncomingMessage): void { this.responseHeaders = {}; @@ -536,6 +564,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Parses the response. * */ private parseResponse(): void { @@ -576,6 +605,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Parses the response encoding. * */ private parseResponseEncoding(): string { From e01541e8a7dad81073bc860efcea7258e9ecaf39 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 26 Jun 2022 20:43:56 +0800 Subject: [PATCH 05/40] #463@minor: Continue added partial support for XMLHttpRequest. --- .../xml-http-request/XMLHttpRequestUpload.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts index 1e418792d..0a77e2443 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts @@ -2,14 +2,14 @@ import { XMLHttpRequestEventTarget } from './XMLHttpRequestEventTarget'; import { ClientRequest } from 'http'; /** - * + * References: https://xhr.spec.whatwg.org/#xmlhttprequestupload. */ export default class XMLHttpRequestUpload extends XMLHttpRequestEventTarget { private _contentType: string | null = null; private _body = null; /** - * + * Create a new XMLHttpRequestUpload object. */ constructor() { super(); @@ -17,7 +17,7 @@ export default class XMLHttpRequestUpload extends XMLHttpRequestEventTarget { } /** - * + * Reset the upload. */ public reset(): void { this._contentType = null; @@ -25,8 +25,9 @@ export default class XMLHttpRequestUpload extends XMLHttpRequestEventTarget { } /** + * Set data to be sent. * - * @param data + * @param data The data to be sent. */ public setData(data?: string | Buffer | ArrayBuffer | ArrayBufferView): void { if (data == null) { @@ -61,9 +62,10 @@ export default class XMLHttpRequestUpload extends XMLHttpRequestEventTarget { } /** + * Finalize headers. * - * @param headers - * @param loweredHeaders + * @param headers The headers to be finalized. + * @param loweredHeaders The lowered headers to be finalized. */ public finalizeHeaders(headers: object, loweredHeaders: object): void { if (this._contentType && !loweredHeaders['content-type']) { @@ -75,8 +77,9 @@ export default class XMLHttpRequestUpload extends XMLHttpRequestEventTarget { } /** + * Start upload. * - * @param request + * @param request The request. */ public startUpload(request: ClientRequest): void { if (this._body) { From 4d5f54c15fcb22e7087d3f0c8ff91569b361f944 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Mon, 27 Jun 2022 12:10:15 +0800 Subject: [PATCH 06/40] #522@patch: Fixes resource load error when websites enabled anti-theft links. --- packages/happy-dom/src/fetch/ResourceFetchHandler.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/happy-dom/src/fetch/ResourceFetchHandler.ts b/packages/happy-dom/src/fetch/ResourceFetchHandler.ts index 41c2a1c92..79f3d8d0c 100644 --- a/packages/happy-dom/src/fetch/ResourceFetchHandler.ts +++ b/packages/happy-dom/src/fetch/ResourceFetchHandler.ts @@ -34,7 +34,14 @@ export default class ResourceFetchHandler { // We want to only load SyncRequest when it is needed to improve performance and not have direct dependencies to server side packages. const absoluteURL = RelativeURL.getAbsoluteURL(document.defaultView.location, url); const syncRequest = require('sync-request'); - const response = syncRequest('GET', absoluteURL); + const response = syncRequest('GET', absoluteURL, { + headers: { + 'user-agent': document.defaultView.navigator.userAgent, + cookie: document.defaultView.document.cookie, + referer: document.defaultView.location.href, + pragma: 'no-cache' + } + }); if (response.isError()) { throw new DOMException( From da34ce5f4136ed2c5b597c463e2a66a9b6e023f4 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Tue, 28 Jun 2022 20:44:29 +0800 Subject: [PATCH 07/40] #463@minor: Continue added partial support for XMLHttpRequest. --- .../src/xml-http-request/XMLHttpRequest.ts | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index b8f7724c3..810d8fed5 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -19,7 +19,8 @@ import RelativeURL from '../location/RelativeURL'; interface IXMLHttpRequestOptions { anon?: boolean; } - +const NodeVersion = process.version.replace('v', '').split('.'); +const MajorNodeVersion = Number.parseInt(NodeVersion[0]); /** * References: https://github.com/souldreamer/xhr2-cookies. */ @@ -225,8 +226,14 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if (this._request == null) { return; } + // ClientRequest.destroy breaks the test suite for versions 10 and 12, + // Hence the version check + if (MajorNodeVersion > 13) { + this._request.destroy(); + } else { + this._request.abort(); + } - this._request.destroy(); this.setError(); this.dispatchProgress('abort'); @@ -474,7 +481,13 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { const request = this._request; this.setError(); - request.destroy(); + // ClientRequest.destroy breaks the test suite for versions 10 and 12, + // Hence the version check + if (MajorNodeVersion > 13) { + request.destroy(); + } else { + request.abort(); + } this.setReadyState(XMLHttpRequest.DONE); this.dispatchProgress('error'); @@ -482,7 +495,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** - * OnHttpRequestError handles the request error. + * OnHttpTimeout handles the timeout. * * @param request The request. */ @@ -492,7 +505,13 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } this.setError(); - request.destroy(); + // ClientRequest.destroy breaks the test suite for versions 10 and 12, + // Hence the version check + if (MajorNodeVersion > 13) { + request.destroy(); + } else { + request.abort(); + } this.setReadyState(XMLHttpRequest.DONE); this.dispatchProgress('timeout'); @@ -512,7 +531,13 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } this.setError(); - request.destroy(); + // ClientRequest.destroy breaks the test suite for versions 10 and 12, + // Hence the version check + if (MajorNodeVersion > 13) { + request.destroy(); + } else { + request.abort(); + } this.setReadyState(XMLHttpRequest.DONE); this.dispatchProgress('error'); From d1f8256d41e004b7525ffef3cbf2c1bcde33f354 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Fri, 1 Jul 2022 12:55:41 +0800 Subject: [PATCH 08/40] #521@patch: Replace URL to native module URL. --- packages/happy-dom/src/fetch/FetchHandler.ts | 2 +- .../src/fetch/ResourceFetchHandler.ts | 5 +- packages/happy-dom/src/location/Location.ts | 4 +- .../happy-dom/src/location/RelativeURL.ts | 33 +----- packages/happy-dom/src/location/URL.ts | 100 +----------------- .../happy-dom/src/location/URLSearchParams.ts | 6 ++ packages/happy-dom/src/window/IWindow.ts | 4 +- packages/happy-dom/src/window/Window.ts | 11 +- .../src/xml-http-request/XMLHttpRequest.ts | 100 ++++++++++-------- .../xml-http-request/XMLHttpRequestUpload.ts | 8 +- .../test/location/RelativeURL.test.ts | 12 +-- 11 files changed, 88 insertions(+), 197 deletions(-) create mode 100644 packages/happy-dom/src/location/URLSearchParams.ts diff --git a/packages/happy-dom/src/fetch/FetchHandler.ts b/packages/happy-dom/src/fetch/FetchHandler.ts index 81fe86b57..755b20292 100644 --- a/packages/happy-dom/src/fetch/FetchHandler.ts +++ b/packages/happy-dom/src/fetch/FetchHandler.ts @@ -24,7 +24,7 @@ export default class FetchHandler { return new Promise((resolve, reject) => { const taskID = taskManager.startTask(); - NodeFetch(RelativeURL.getAbsoluteURL(document.defaultView.location, url), init) + NodeFetch(RelativeURL.getAbsoluteURL(document.defaultView.location, url).href, init) .then((response) => { if (taskManager.getTaskCount() === 0) { reject(new Error('Failed to complete fetch request. Task was canceled.')); diff --git a/packages/happy-dom/src/fetch/ResourceFetchHandler.ts b/packages/happy-dom/src/fetch/ResourceFetchHandler.ts index 79f3d8d0c..176880555 100644 --- a/packages/happy-dom/src/fetch/ResourceFetchHandler.ts +++ b/packages/happy-dom/src/fetch/ResourceFetchHandler.ts @@ -32,14 +32,13 @@ export default class ResourceFetchHandler { */ public static fetchSync(document: IDocument, url: string): string { // We want to only load SyncRequest when it is needed to improve performance and not have direct dependencies to server side packages. - const absoluteURL = RelativeURL.getAbsoluteURL(document.defaultView.location, url); + const absoluteURL = RelativeURL.getAbsoluteURL(document.defaultView.location, url).href; const syncRequest = require('sync-request'); const response = syncRequest('GET', absoluteURL, { headers: { 'user-agent': document.defaultView.navigator.userAgent, cookie: document.defaultView.document.cookie, - referer: document.defaultView.location.href, - pragma: 'no-cache' + referer: document.defaultView.location.origin } }); diff --git a/packages/happy-dom/src/location/Location.ts b/packages/happy-dom/src/location/Location.ts index 4d6599e8d..0e16a3671 100644 --- a/packages/happy-dom/src/location/Location.ts +++ b/packages/happy-dom/src/location/Location.ts @@ -8,7 +8,7 @@ export default class Location extends URL { * Constructor. */ constructor() { - super(''); + super('about:blank'); } /** @@ -17,7 +17,7 @@ export default class Location extends URL { * @param url URL. */ public replace(url: string): void { - this.parse(url); + this.href = url; } /** diff --git a/packages/happy-dom/src/location/RelativeURL.ts b/packages/happy-dom/src/location/RelativeURL.ts index 1ae92557d..53a04ab5f 100644 --- a/packages/happy-dom/src/location/RelativeURL.ts +++ b/packages/happy-dom/src/location/RelativeURL.ts @@ -1,4 +1,5 @@ import Location from './Location'; +import URL from './URL'; /** * Helper class for getting the URL relative to a Location object. @@ -10,35 +11,7 @@ export default class RelativeURL { * @param location Location. * @param url URL. */ - public static getAbsoluteURL(location: Location, url: string): string { - // If the URL starts with '//' then it is a Protocol relative URL. - // Reference: https://url.spec.whatwg.org/#protocol-relative-urls. - // E.g. '//example.com/' needs to be converted to 'http://example.com/'. - if (url.startsWith('//')) { - return location.protocol + url; - } - // If the URL starts with '/' then it is a Path relative URL. - // E.g. '/example.com/' needs to be converted to 'http://example.com/'. - if (url.startsWith('/')) { - return location.origin + url; - } - // If the URL starts with 'https://' or 'http://' then it is a Absolute URL. - // E.g. 'https://example.com' needs to be converted to 'https://example.com/'. - // E.g. 'http://example.com' needs to be converted to 'http://example.com/'. - if (!url.startsWith('https://') && !url.startsWith('http://')) { - let pathname = location.pathname; - if (pathname.endsWith('/')) { - pathname = pathname.slice(0, -1); - } - - return ( - location.origin + - (/(.*)\/.*/.test(pathname) ? pathname.match(/(.*)\/.*/)[1] : '') + - '/' + - url - ); - } - - return url; + public static getAbsoluteURL(location: Location, url: string): URL { + return new URL(url, location.href); } } diff --git a/packages/happy-dom/src/location/URL.ts b/packages/happy-dom/src/location/URL.ts index e66e4317f..2d52a7c47 100644 --- a/packages/happy-dom/src/location/URL.ts +++ b/packages/happy-dom/src/location/URL.ts @@ -1,102 +1,6 @@ -const URL_REGEXP = - /(https?:)\/\/([-a-zA-Z0-9@:%._\+~#=]{2,256}[a-z]{2,6})(:[0-9]*)?([-a-zA-Z0-9@:%_\+.~c&//=]*)(\?[^#]*)?(#.*)?/; -const PATH_REGEXP = /([-a-zA-Z0-9@:%_\+.~c&//=]*)(\?[^#]*)?(#.*)?/; +import { URL as Url } from 'url'; /** * */ -export default class URL { - public protocol = ''; - public hostname = ''; - public port = ''; - public pathname = ''; - public search = ''; - public hash = ''; - public username = ''; - public password = ''; - - /** - * Constructor. - * - * @param [url] URL. - */ - constructor(url?: string) { - if (url) { - this.parse(url); - } - } - - /** - * Returns the entire URL as a string. - * - * @returns Href. - */ - public get href(): string { - const credentials = this.username ? `${this.username}:${this.password}@` : ''; - return this.protocol + '//' + credentials + this.host + this.pathname + this.search + this.hash; - } - - /** - * Sets the href. - * - * @param url URL. - */ - public set href(url: string) { - this.parse(url); - } - - /** - * Returns the origin. - * - * @returns HREF. - */ - public get origin(): string { - return this.protocol + '//' + this.host; - } - - /** - * Returns the entire URL as a string. - * - * @returns Host. - */ - public get host(): string { - return this.hostname + this.port; - } - - /** - * Returns the entire URL as a string. - */ - public toString(): string { - return this.href; - } - - /** - * Parses an URL. - * - * @param url URL. - */ - protected parse(url: string): void { - const match = url.match(URL_REGEXP); - - if (match) { - const hostnamePart = match[2] ? match[2].split('@') : ''; - const credentialsPart = hostnamePart.length > 1 ? hostnamePart[0].split(':') : null; - - this.protocol = match[1] || ''; - this.hostname = hostnamePart.length > 1 ? hostnamePart[1] : hostnamePart[0]; - this.port = match[3] || ''; - this.pathname = match[4] || '/'; - this.search = match[5] || ''; - this.hash = match[6] || ''; - this.username = credentialsPart ? credentialsPart[0] : ''; - this.password = credentialsPart ? credentialsPart[1] : ''; - } else { - const pathMatch = url.match(PATH_REGEXP); - if (pathMatch) { - this.pathname = pathMatch[1] || ''; - this.search = pathMatch[2] || ''; - this.hash = pathMatch[3] || ''; - } - } - } -} +export default class URL extends Url {} diff --git a/packages/happy-dom/src/location/URLSearchParams.ts b/packages/happy-dom/src/location/URLSearchParams.ts new file mode 100644 index 000000000..fbfd6dbba --- /dev/null +++ b/packages/happy-dom/src/location/URLSearchParams.ts @@ -0,0 +1,6 @@ +import { URLSearchParams as UrlSearchParams } from 'url'; + +/** + * + */ +export default class URLSearchParams extends UrlSearchParams {} diff --git a/packages/happy-dom/src/window/IWindow.ts b/packages/happy-dom/src/window/IWindow.ts index b2d23151f..1574bcbf3 100644 --- a/packages/happy-dom/src/window/IWindow.ts +++ b/packages/happy-dom/src/window/IWindow.ts @@ -37,6 +37,7 @@ import KeyboardEvent from '../event/events/KeyboardEvent'; import ProgressEvent from '../event/events/ProgressEvent'; import EventTarget from '../event/EventTarget'; import URL from '../location/URL'; +import URLSearchParams from '../location/URLSearchParams'; import Location from '../location/Location'; import MutationObserver from '../mutation-observer/MutationObserver'; import DOMParser from '../dom-parser/DOMParser'; @@ -84,7 +85,6 @@ import Range from '../range/Range'; import MediaQueryList from '../match-media/MediaQueryList'; import DOMRect from '../nodes/element/DOMRect'; import Window from './Window'; -import { URLSearchParams } from 'url'; import { Performance } from 'perf_hooks'; /** @@ -150,6 +150,7 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { readonly DataTransferItem: typeof DataTransferItem; readonly DataTransferItemList: typeof DataTransferItemList; readonly URL: typeof URL; + readonly URLSearchParams: typeof URLSearchParams; readonly Location: typeof Location; readonly CustomElementRegistry: typeof CustomElementRegistry; readonly Window: typeof Window; @@ -163,7 +164,6 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { readonly History: typeof History; readonly Screen: typeof Screen; readonly Storage: typeof Storage; - readonly URLSearchParams: typeof URLSearchParams; readonly HTMLCollection: typeof HTMLCollection; readonly NodeList: typeof NodeList; readonly CSSUnitValue: typeof CSSUnitValue; diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index bbc969756..46aa1fc4f 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -37,6 +37,7 @@ import KeyboardEvent from '../event/events/KeyboardEvent'; import ProgressEvent from '../event/events/ProgressEvent'; import EventTarget from '../event/EventTarget'; import URL from '../location/URL'; +import URLSearchParams from '../location/URLSearchParams'; import Location from '../location/Location'; import NonImplementedEventTypes from '../event/NonImplementedEventTypes'; import MutationObserver from '../mutation-observer/MutationObserver'; @@ -86,7 +87,6 @@ import MimeType from '../navigator/MimeType'; import MimeTypeArray from '../navigator/MimeTypeArray'; import Plugin from '../navigator/Plugin'; import PluginArray from '../navigator/PluginArray'; -import { URLSearchParams } from 'url'; import FetchHandler from '../fetch/FetchHandler'; import { default as RangeImplementation } from '../range/Range'; import DOMRect from '../nodes/element/DOMRect'; @@ -209,11 +209,6 @@ export default class Window extends EventTarget implements IWindow { public readonly FileReader; public readonly Image; - // XMLHttpRequest - public XMLHttpRequest = XMLHttpRequest; - public XMLHttpRequestUpload = XMLHttpRequestUpload; - public XMLHttpRequestEventTarget = XMLHttpRequestEventTarget; - // Events public onload: (event: Event) => void = null; public onerror: (event: ErrorEvent) => void = null; @@ -238,6 +233,10 @@ export default class Window extends EventTarget implements IWindow { public readonly localStorage = new Storage(); public readonly performance = PerfHooks.performance; + public XMLHttpRequest = XMLHttpRequest; + public XMLHttpRequestUpload = XMLHttpRequestUpload; + public XMLHttpRequestEventTarget = XMLHttpRequestEventTarget; + // Node.js Globals public ArrayBuffer; public Boolean; diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 810d8fed5..ea28b8fba 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -15,12 +15,13 @@ import DOMException from '../exception/DOMException'; import IWindow from '../window/IWindow'; import URL from '../location/URL'; import RelativeURL from '../location/RelativeURL'; +import Blob from '../file/Blob'; +import { + copyToArrayBuffer, + MajorNodeVersion, + IXMLHttpRequestOptions +} from './XMLHttpReqeustUtility'; -interface IXMLHttpRequestOptions { - anon?: boolean; -} -const NodeVersion = process.version.replace('v', '').split('.'); -const MajorNodeVersion = Number.parseInt(NodeVersion[0]); /** * References: https://github.com/souldreamer/xhr2-cookies. */ @@ -112,6 +113,11 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param password The password to use for authentication purposes. */ public open(method: string, url: string, async = true, user?: string, password?: string): void { + const { _defaultView } = XMLHttpRequest; + // If _defaultView is not defined, then we can't set the URL. + if (!_defaultView) { + throw new Error('need set defaultView'); + } method = method.toUpperCase(); if (this.restrictedMethods[method]) { throw new DOMException( @@ -119,14 +125,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { DOMExceptionNameEnum.securityError ); } - // If _defaultView is not defined, then we can't set the URL. - if (!XMLHttpRequest._defaultView) { - throw new Error('need set defaultView'); - } + // Get and Parse the URL relative to the given Location object. - const xhrUrl = new XMLHttpRequest._defaultView.URL( - RelativeURL.getAbsoluteURL(XMLHttpRequest._defaultView.location, url) - ); + const xhrUrl = RelativeURL.getAbsoluteURL(XMLHttpRequest._defaultView.location, url); // Set username and password if given. xhrUrl.username = user ? user : xhrUrl.username; xhrUrl.password = password ? password : xhrUrl.password; @@ -163,6 +164,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param value The value to set as the body of the header. */ public setRequestHeader(name: string, value: unknown): void { + const { _defaultView } = XMLHttpRequest; if (this.readyState !== XMLHttpRequest.OPENED) { throw new DOMException( 'XHR readyState must be OPENED', @@ -176,7 +178,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { /^sec-/.test(loweredName) || /^proxy-/.test(loweredName) ) { - XMLHttpRequest._defaultView.console.warn(`Refused to set unsafe header "${name}"`); + _defaultView.console.warn(`Refused to set unsafe header "${name}"`); return; } @@ -196,14 +198,12 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param data The data to send with the request. */ public send(data?: string | Buffer | ArrayBuffer | ArrayBufferView): void { + const { invalidStateError, networkError } = DOMExceptionNameEnum; if (this.readyState !== XMLHttpRequest.OPENED) { - throw new DOMException( - 'XHR readyState must be OPENED', - DOMExceptionNameEnum.invalidStateError - ); + throw new DOMException('XHR readyState must be OPENED', invalidStateError); } if (this._request) { - throw new DOMException('send() already called', DOMExceptionNameEnum.invalidStateError); + throw new DOMException('send() already called', invalidStateError); } switch (this.url.protocol) { case 'file:': @@ -212,10 +212,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { case 'https:': return this.sendHttp(data); default: - throw new DOMException( - `Unsupported protocol ${this.url.protocol}`, - DOMExceptionNameEnum.networkError - ); + throw new DOMException(`Unsupported protocol ${this.url.protocol}`, networkError); } } @@ -233,7 +230,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } else { this._request.abort(); } - + this.setReadyState(XMLHttpRequest.DONE); this.setError(); this.dispatchProgress('abort'); @@ -246,7 +243,11 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param name The name of the header. */ public getResponseHeader(name: string): string { - if (this.responseHeaders == null || name == null) { + if ( + this.responseHeaders == null || + name == null || + this.readyState in [XMLHttpRequest.OPENED, XMLHttpRequest.UNSENT] + ) { return null; } const loweredName = name.toLowerCase(); @@ -260,7 +261,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * */ public getAllResponseHeaders(): string { - if (this.responseHeaders == null) { + if ( + this.responseHeaders == null || + this.readyState in [XMLHttpRequest.OPENED, XMLHttpRequest.UNSENT] + ) { return ''; } return Object.keys(this.responseHeaders) @@ -274,7 +278,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param mimeType The MIME type to use. */ public overrideMimeType(mimeType: string): void { - if (this.readyState === XMLHttpRequest.LOADING || this.readyState === XMLHttpRequest.DONE) { + if (this.readyState in [XMLHttpRequest.LOADING, XMLHttpRequest.DONE]) { throw new DOMException( 'overrideMimeType() not allowed in LOADING or DONE', DOMExceptionNameEnum.invalidStateError @@ -296,6 +300,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { /** * Send request with file. * + * @todo Not implemented. * @param _data File body to send. */ private sendFile(_data: unknown): void { @@ -309,13 +314,13 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param data Data to send. */ private sendHttp(data?: string | Buffer | ArrayBuffer | ArrayBufferView): void { + const { _defaultView } = XMLHttpRequest; if (this.sync) { + // TODO: sync not implemented. throw new Error('Synchronous XHR processing not implemented'); } if (data && (this.method === 'GET' || this.method === 'HEAD')) { - XMLHttpRequest._defaultView.console.warn( - `Discarding entity body for ${this.method} requests` - ); + _defaultView.console.warn(`Discarding entity body for ${this.method} requests`); data = null; } else { data = data || ''; @@ -331,10 +336,11 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * */ private sendHxxpRequest(): void { + const { _defaultView } = XMLHttpRequest; if (this.withCredentials) { // Set cookie if URL is same-origin. - if (XMLHttpRequest._defaultView.location.origin === this.url.origin) { - this.headers.cookie = XMLHttpRequest._defaultView.document.cookie; + if (_defaultView.location.origin === this.url.origin) { + this.headers.cookie = _defaultView.document.cookie; } } @@ -369,11 +375,12 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * */ private finalizeHeaders(): void { + const { _defaultView } = XMLHttpRequest; this.headers = { ...this.headers, Connection: 'keep-alive', Host: this.url.host, - 'User-Agent': XMLHttpRequest._defaultView.navigator.userAgent, + 'User-Agent': _defaultView.navigator.userAgent, ...(this.anonymous ? { Referer: 'about:blank' } : {}) }; this.upload.finalizeHeaders(this.headers, this.loweredHeaders); @@ -389,13 +396,14 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if (this._request !== request) { return; } + const { _defaultView } = XMLHttpRequest; if (this.withCredentials && response.headers['set-cookie']) { - XMLHttpRequest._defaultView.document.cookie = response.headers['set-cookie'].join('; '); + _defaultView.document.cookie = response.headers['set-cookie'].join('; '); } if ([301, 302, 303, 307, 308].indexOf(response.statusCode) >= 0) { - this.url = new XMLHttpRequest._defaultView.URL(response.headers.location); + this.url = new _defaultView.URL(response.headers.location); this.method = 'GET'; if (this.loweredHeaders['content-type']) { delete this.headers[this.loweredHeaders['content-type']]; @@ -604,20 +612,24 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } catch { this.response = null; } - return; + break; case 'buffer': this.responseText = null; this.response = buffer; - return; + break; case 'arraybuffer': this.responseText = null; - const arrayBuffer = new ArrayBuffer(buffer.length); - const view = new Uint8Array(arrayBuffer); - for (let i = 0; i < buffer.length; i++) { - view[i] = buffer[i]; - } - this.response = arrayBuffer; - return; + this.response = copyToArrayBuffer(buffer); + break; + case 'blob': + this.responseText = null; + this.response = new Blob([new Uint8Array(buffer)], { + type: this.mimeOverride || this.responseHeaders['content-type'] || '' + }); + break; + case 'document': + // TODO: MimeType parse not yet supported. + break; case 'text': default: try { @@ -626,7 +638,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this.responseText = buffer.toString('binary'); } this.response = this.responseText; + break; } + return; } /** diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts index 0a77e2443..da9c8c710 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts @@ -44,17 +44,13 @@ export default class XMLHttpRequestUpload extends XMLHttpRequestEventTarget { } else if (data instanceof ArrayBuffer) { const body = Buffer.alloc(data.byteLength); const view = new Uint8Array(data); - for (let i = 0; i < data.byteLength; i++) { - body[i] = view[i]; - } + body.set(view); this._body = body; } else if (data.buffer && data.buffer instanceof ArrayBuffer) { const body = Buffer.alloc(data.byteLength); const offset = data.byteOffset; const view = new Uint8Array(data.buffer); - for (let i = 0; i < data.byteLength; i++) { - body[i] = view[i + offset]; - } + body.set(view, offset); this._body = body; } else { throw new Error(`Unsupported send() data ${data}`); diff --git a/packages/happy-dom/test/location/RelativeURL.test.ts b/packages/happy-dom/test/location/RelativeURL.test.ts index 29ec0a990..010341279 100644 --- a/packages/happy-dom/test/location/RelativeURL.test.ts +++ b/packages/happy-dom/test/location/RelativeURL.test.ts @@ -11,22 +11,22 @@ describe('RelativeURL', () => { describe('getAbsoluteURL()', () => { it('Returns absolute URL when location is "https://localhost:8080/base/" and URL is "path/to/resource/".', () => { location.href = 'https://localhost:8080/base/'; - expect(RelativeURL.getAbsoluteURL(location, 'path/to/resource/')).toBe( - 'https://localhost:8080/path/to/resource/' + expect(RelativeURL.getAbsoluteURL(location, 'path/to/resource/').href).toBe( + 'https://localhost:8080/base/path/to/resource/' ); }); it('Returns absolute URL when location is "https://localhost:8080" and URL is "path/to/resource/".', () => { location.href = 'https://localhost:8080'; - expect(RelativeURL.getAbsoluteURL(location, 'path/to/resource/')).toBe( + expect(RelativeURL.getAbsoluteURL(location, 'path/to/resource/').href).toBe( 'https://localhost:8080/path/to/resource/' ); }); it('Returns absolute URL when URL is "https://localhost:8080/path/to/resource/".', () => { - expect(RelativeURL.getAbsoluteURL(location, 'https://localhost:8080/path/to/resource/')).toBe( - 'https://localhost:8080/path/to/resource/' - ); + expect( + RelativeURL.getAbsoluteURL(location, 'https://localhost:8080/path/to/resource/').href + ).toBe('https://localhost:8080/path/to/resource/'); }); }); }); From a1c0e816b12b14bbed27e2ef4acc283bf4a226d2 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Fri, 1 Jul 2022 13:19:29 +0800 Subject: [PATCH 09/40] #521@patch: Fixes test units error. --- packages/happy-dom/test/location/URL.test.ts | 2 +- packages/happy-dom/test/nodes/document/Document.test.ts | 4 ++-- packages/happy-dom/test/window/Window.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/happy-dom/test/location/URL.test.ts b/packages/happy-dom/test/location/URL.test.ts index 7a8477f49..3e94013cc 100644 --- a/packages/happy-dom/test/location/URL.test.ts +++ b/packages/happy-dom/test/location/URL.test.ts @@ -40,7 +40,7 @@ describe('URL', () => { expect(url.href).toBe(href); expect(url.protocol).toBe('https:'); expect(url.hostname).toBe('google.com'); - expect(url.port).toBe(':8080'); + expect(url.port).toBe('8080'); expect(url.pathname).toBe('/some-path/'); expect(url.search).toBe(''); expect(url.hash).toBe(''); diff --git a/packages/happy-dom/test/nodes/document/Document.test.ts b/packages/happy-dom/test/nodes/document/Document.test.ts index 02c151f2a..5a349c028 100644 --- a/packages/happy-dom/test/nodes/document/Document.test.ts +++ b/packages/happy-dom/test/nodes/document/Document.test.ts @@ -184,7 +184,7 @@ describe('Document', () => { }); it('Sets a cookie with a path.', () => { - window.location.href = '/path/to/cookie/'; + window.location.href = 'https://sub.test.com/path/to/cookie/'; document.cookie = 'name1=value1; path=path/to'; document.cookie = 'name2=value2; path=/path/to'; document.cookie = 'name3=value3; path=/path/to/cookie/'; @@ -192,7 +192,7 @@ describe('Document', () => { }); it('Does not set cookie if the path does not match the current path.', () => { - window.location.href = '/path/to/cookie/'; + window.location.href = 'https://sub.test.com/path/to/cookie/'; document.cookie = 'name1=value1; path=/cookie/'; expect(document.cookie).toBe(''); }); diff --git a/packages/happy-dom/test/window/Window.test.ts b/packages/happy-dom/test/window/Window.test.ts index 5c2239e25..34eb6a6fe 100644 --- a/packages/happy-dom/test/window/Window.test.ts +++ b/packages/happy-dom/test/window/Window.test.ts @@ -24,6 +24,7 @@ describe('Window', () => { MOCKED_NODE_FETCH.error = null; window = new Window(); document = window.document; + window.location.href = 'http://localhost:8080/'; }); afterEach(() => { @@ -322,7 +323,6 @@ describe('Window', () => { it('Handles error JSON request.', async () => { MOCKED_NODE_FETCH.error = new Error('error'); - try { await window.fetch('/url/', {}); } catch (error) { From 9f9df585d28ed59d94802a25736cafa46fd1ab47 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Fri, 1 Jul 2022 13:22:41 +0800 Subject: [PATCH 10/40] #521@minor: Continue added partial support for XMLHttpRequest. --- .../src/xml-http-request/XMLHttpReqeustUtility.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 packages/happy-dom/src/xml-http-request/XMLHttpReqeustUtility.ts diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpReqeustUtility.ts b/packages/happy-dom/src/xml-http-request/XMLHttpReqeustUtility.ts new file mode 100644 index 000000000..17ee0ac44 --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/XMLHttpReqeustUtility.ts @@ -0,0 +1,13 @@ +const NodeVersion = process.version.replace('v', '').split('.'); + +export interface IXMLHttpRequestOptions { + anon?: boolean; +} + +export const MajorNodeVersion = Number.parseInt(NodeVersion[0]); +export const copyToArrayBuffer = (buffer: Buffer, offset?: number): ArrayBuffer => { + const arrayBuffer = new ArrayBuffer(buffer.length); + const view = new Uint8Array(arrayBuffer); + view.set(buffer, offset || 0); + return arrayBuffer; +}; From a71819f7bd813a3e0bc528f9fa8995a0896f52d7 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Fri, 1 Jul 2022 13:59:42 +0800 Subject: [PATCH 11/40] #526@minor: Adds support for Document.documentURI and Document.URL. --- .../happy-dom/src/nodes/document/Document.ts | 18 ++++++++++++++++++ .../happy-dom/src/nodes/document/IDocument.ts | 2 ++ .../test/nodes/document/Document.test.ts | 13 +++++++++++++ 3 files changed, 33 insertions(+) diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts index 43235f9ac..e3fcd72dd 100644 --- a/packages/happy-dom/src/nodes/document/Document.ts +++ b/packages/happy-dom/src/nodes/document/Document.ts @@ -281,6 +281,24 @@ export default class Document extends Node implements IDocument { return this.defaultView.location.href; } + /** + * Returns URL. + * + * @returns the URL of the current document. + * */ + public get URL(): string { + return this.defaultView.location.href; + } + + /** + * Returns document URI. + * + * @returns the URL of the current document. + * */ + public get documentURI(): string { + return this.URL; + } + /** * Inserts a set of Node objects or DOMString objects after the last child of the ParentNode. DOMString objects are inserted as equivalent Text nodes. * diff --git a/packages/happy-dom/src/nodes/document/IDocument.ts b/packages/happy-dom/src/nodes/document/IDocument.ts index a1967ec27..7ce365831 100644 --- a/packages/happy-dom/src/nodes/document/IDocument.ts +++ b/packages/happy-dom/src/nodes/document/IDocument.ts @@ -39,6 +39,8 @@ export default interface IDocument extends IParentNode { readonly readyState: DocumentReadyStateEnum; readonly charset: string; readonly characterSet: string; + readonly URL: string; + readonly documentURI: string; cookie: string; /** diff --git a/packages/happy-dom/test/nodes/document/Document.test.ts b/packages/happy-dom/test/nodes/document/Document.test.ts index 5a349c028..551c07ac9 100644 --- a/packages/happy-dom/test/nodes/document/Document.test.ts +++ b/packages/happy-dom/test/nodes/document/Document.test.ts @@ -398,6 +398,19 @@ describe('Document', () => { }); }); + describe('URL', () => { + it('Returns the URL of the document.', () => { + document.location.href = 'http://localhost:8080/path/to/file.html'; + expect(document.URL).toBe('http://localhost:8080/path/to/file.html'); + }); + }); + describe('documentURI', () => { + it('Returns the documentURI of the document.', () => { + document.location.href = 'http://localhost:8080/path/to/file.html'; + expect(document.documentURI).toBe('http://localhost:8080/path/to/file.html'); + }); + }); + describe('append()', () => { it('Inserts a set of Node objects or DOMString objects after the last child of the ParentNode. DOMString objects are inserted as equivalent Text nodes.', () => { const node1 = document.createComment('test1'); From 1ff5b9a2620a46f1420fd17207c313b3bab8b8cf Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 3 Jul 2022 23:04:56 +0800 Subject: [PATCH 12/40] #526@minor: Continue Adds support for XMLHttpRequest (Sync) and remove outdated annotation, fixes some typo error. --- .../happy-dom/src/event/IEventListener.ts | 2 +- packages/happy-dom/src/fetch/FetchHandler.ts | 10 ++- .../src/fetch/ResourceFetchHandler.ts | 4 +- .../happy-dom/src/nodes/document/Document.ts | 2 - packages/happy-dom/src/window/IWindow.ts | 4 +- .../src/xml-http-request/XMLHttpRequest.ts | 60 ++++++++++++--- .../XMLHttpRequestSyncWorker.ts | 73 +++++++++++++++++++ 7 files changed, 137 insertions(+), 18 deletions(-) create mode 100644 packages/happy-dom/src/xml-http-request/XMLHttpRequestSyncWorker.ts diff --git a/packages/happy-dom/src/event/IEventListener.ts b/packages/happy-dom/src/event/IEventListener.ts index b731998d7..0c9e5cbee 100644 --- a/packages/happy-dom/src/event/IEventListener.ts +++ b/packages/happy-dom/src/event/IEventListener.ts @@ -7,7 +7,7 @@ export default interface IEventListener { /** * Handles event. * - * @param type Event type. + * @param event */ handleEvent(event: Event): void; } diff --git a/packages/happy-dom/src/fetch/FetchHandler.ts b/packages/happy-dom/src/fetch/FetchHandler.ts index 755b20292..f4da6a206 100644 --- a/packages/happy-dom/src/fetch/FetchHandler.ts +++ b/packages/happy-dom/src/fetch/FetchHandler.ts @@ -10,7 +10,7 @@ import NodeFetch from 'node-fetch'; */ export default class FetchHandler { /** - * Returns resource data asynchonously. + * Returns resource data asynchronously. * * @param document Document. * @param url URL to resource. @@ -20,7 +20,13 @@ export default class FetchHandler { public static fetch(document: IDocument, url: string, init?: IRequestInit): Promise { // We want to only load NodeFetch when it is needed to improve performance and not have direct dependencies to server side packages. const taskManager = document.defaultView.happyDOM.asyncTaskManager; - + // We need set referer to solve anti-hotlinking. + // And the browser will set the referer to the origin of the page. + if (init) { + if (!init.headers['referer']) { + init.headers['referer'] = document.defaultView.location.origin; + } + } return new Promise((resolve, reject) => { const taskID = taskManager.startTask(); diff --git a/packages/happy-dom/src/fetch/ResourceFetchHandler.ts b/packages/happy-dom/src/fetch/ResourceFetchHandler.ts index 176880555..5e83c2c84 100644 --- a/packages/happy-dom/src/fetch/ResourceFetchHandler.ts +++ b/packages/happy-dom/src/fetch/ResourceFetchHandler.ts @@ -7,7 +7,7 @@ import IDocument from '../nodes/document/IDocument'; */ export default class ResourceFetchHandler { /** - * Returns resource data asynchonously. + * Returns resource data asynchronously. * * @param document Document. * @param url URL. @@ -24,7 +24,7 @@ export default class ResourceFetchHandler { } /** - * Returns resource data synchonously. + * Returns resource data synchronously. * * @param document Document. * @param url URL. diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts index e3fcd72dd..e47532179 100644 --- a/packages/happy-dom/src/nodes/document/Document.ts +++ b/packages/happy-dom/src/nodes/document/Document.ts @@ -64,7 +64,6 @@ export default class Document extends Node implements IDocument { /** * Creates an instance of Document. * - * @param defaultView Default view. */ constructor() { super(); @@ -764,7 +763,6 @@ export default class Document extends Node implements IDocument { * * @param node Node to import. * @param [deep=false] Set to "true" if the clone should be deep. - * @param Imported Node. */ public importNode(node: INode, deep = false): INode { if (!(node instanceof Node)) { diff --git a/packages/happy-dom/src/window/IWindow.ts b/packages/happy-dom/src/window/IWindow.ts index 1574bcbf3..88fa0b7aa 100644 --- a/packages/happy-dom/src/window/IWindow.ts +++ b/packages/happy-dom/src/window/IWindow.ts @@ -310,7 +310,7 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { * Creates a Base64-encoded ASCII string from a binary string (i.e., a string in which each character in the string is treated as a byte of binary data). * * @see https://developer.mozilla.org/en-US/docs/Web/API/btoa - * @param data Binay data. + * @param data Binary data. * @returns Base64-encoded string. */ btoa(data: unknown): string; @@ -321,7 +321,7 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { * @see https://developer.mozilla.org/en-US/docs/Web/API/atob * @see https://infra.spec.whatwg.org/#forgiving-base64-encode. * @see Https://html.spec.whatwg.org/multipage/webappapis.html#btoa. - * @param data Binay string. + * @param data Binary string. * @returns An ASCII string containing decoded data from encodedData. */ atob(data: unknown): string; diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index ea28b8fba..8e40e19c8 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -21,6 +21,8 @@ import { MajorNodeVersion, IXMLHttpRequestOptions } from './XMLHttpReqeustUtility'; +import { spawnSync } from "child_process"; +const SyncWorkerFile = require.resolve ? require.resolve('./XMLHttpRequestSyncWorker') : null; /** * References: https://github.com/souldreamer/xhr2-cookies. @@ -46,13 +48,15 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { public upload = new XMLHttpRequestUpload(); public responseUrl = ''; public withCredentials = false; - // TODO: another way to set proxy? + public nodejsHttpAgent: HttpAgent = http.globalAgent; public nodejsHttpsAgent: HttpsAgent = https.globalAgent; private readonly anonymous: boolean; private method: string | null = null; private url: URL | null = null; + private auth: string | null = null; + private body: string | Buffer | ArrayBuffer | ArrayBufferView; private sync = false; private headers: { [header: string]: string } = {}; private loweredHeaders: { [lowercaseHeader: string]: string } = {}; @@ -60,7 +64,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { private _request: ClientRequest | null = null; private _response: IncomingMessage | null = null; // @ts-ignore - private _error: Error | null = null; + private _error: Error | string | null = null; private responseParts: Buffer[] | null = null; private responseHeaders: { [lowercaseHeader: string]: string } | null = null; private loadedBytes = 0; @@ -141,10 +145,11 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this.method = method; this.url = xhrUrl; + this.auth = `${this.url.username || ''}:${this.url.password || ''}` this.sync = !async; - this.headers = {}; + // this.headers = {}; this.loweredHeaders = {}; - this.mimeOverride = null; + // this.mimeOverride = null; this.setReadyState(XMLHttpRequest.OPENED); this._request = null; this._response = null; @@ -155,6 +160,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this.loadedBytes = 0; this.totalBytes = 0; this.lengthComputable = false; + } /** @@ -246,7 +252,8 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if ( this.responseHeaders == null || name == null || - this.readyState in [XMLHttpRequest.OPENED, XMLHttpRequest.UNSENT] + this.readyState === XMLHttpRequest.OPENED || + this.readyState === XMLHttpRequest.UNSENT ) { return null; } @@ -263,7 +270,8 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { public getAllResponseHeaders(): string { if ( this.responseHeaders == null || - this.readyState in [XMLHttpRequest.OPENED, XMLHttpRequest.UNSENT] + this.readyState === XMLHttpRequest.OPENED || + this.readyState === XMLHttpRequest.UNSENT ) { return ''; } @@ -278,7 +286,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param mimeType The MIME type to use. */ public overrideMimeType(mimeType: string): void { - if (this.readyState in [XMLHttpRequest.LOADING, XMLHttpRequest.DONE]) { + if (this.readyState === XMLHttpRequest.LOADING || this.readyState === XMLHttpRequest.DONE) { throw new DOMException( 'overrideMimeType() not allowed in LOADING or DONE', DOMExceptionNameEnum.invalidStateError @@ -315,7 +323,12 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { */ private sendHttp(data?: string | Buffer | ArrayBuffer | ArrayBufferView): void { const { _defaultView } = XMLHttpRequest; + this.body = data; + if (this.sync) { + const params = this._serialParams(); + const res = spawnSync(process.execPath, [SyncWorkerFile], { input: params, maxBuffer: Infinity }); + res; // TODO: sync not implemented. throw new Error('Synchronous XHR processing not implemented'); } @@ -351,7 +364,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { hostname: this.url.hostname, port: +this.url.port, path: this.url.pathname, - auth: `${this.url.username || ''}:${this.url.password || ''}`, + auth: this.auth, method: this.method, headers: this.headers, agent @@ -381,7 +394,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { Connection: 'keep-alive', Host: this.url.host, 'User-Agent': _defaultView.navigator.userAgent, - ...(this.anonymous ? { Referer: 'about:blank' } : {}) + ...(this.anonymous ? { Referer: 'about:blank' } : { Referer: _defaultView.location.origin }) }; this.upload.finalizeHeaders(this.headers, this.loweredHeaders); } @@ -651,4 +664,33 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { const charset = /;\s*charset=(.*)$/.exec(this.responseHeaders['content-type'] || ''); return Array.isArray(charset) ? charset[1] : 'utf-8'; } + + public _syncGetError(): Error { + return this._error; + } + public _syncSetErrorString(error: string): void { + this._error = error; + } + + private _serialParams(): string { + const { _defaultView } = XMLHttpRequest; + const serials = { + sync: this.sync, + withCredentials: this.withCredentials, + mimeType: this.mimeOverride, + username: this.url.username, + password: this.url.password, + auth: this.auth, + method: this.method, + responseType: this.responseType, + headers: this.headers, + uri: this.url.href, + timeout: this.timeout, + body: this.body, + + cookie: _defaultView.document.cookie, + origin: _defaultView.location.href + }; + return JSON.stringify(serials); + } } diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestSyncWorker.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestSyncWorker.ts new file mode 100644 index 000000000..ed97d8eaa --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestSyncWorker.ts @@ -0,0 +1,73 @@ +import Window from '../window/Window'; +import * as util from 'util'; + +const window = new Window(); +const xhr = new window.XMLHttpRequest(); + +const chunks = []; + +process.stdin.on('data', (chunk) => { + chunks.push(chunk); +}); + +process.stdin.on('end', () => { + const buffer = Buffer.concat(chunks); + + const serials = JSON.parse(buffer.toString()); + if (serials.body && serials.body.type === 'Buffer' && serials.body.data) { + serials.body = Buffer.from(serials.body.data); + } + if (serials.origin) { + window.location.href = serials.origin; + } + + if (serials.cookie) { + window.document.cookie = serials.cookie; + } + + xhr.overrideMimeType(serials.mimeType); + xhr.open(serials.method, serials.uri, true, serials.user, serials.password); + if (serials.headers) { + Object.keys(serials.headers).forEach((key) => { + xhr.setRequestHeader(key, serials.headers[key]); + }); + } + + + xhr.timeout = serials.timeout; + + try { + xhr.addEventListener('loadend', () => { + if (xhr._syncGetError()) { + const err = xhr._syncGetError(); + xhr._syncSetErrorString(err.stack || util.inspect(err)); + } + + process.stdout.write( + JSON.stringify({ + responseURL: xhr.responseUrl, + responseText: xhr.responseText, + status: xhr.status, + statusText: xhr.statusText, + }), + () => { + process.exit(0); + } + ); + }); + + xhr.send(serials.body); + } catch (error) { + // Properties.error += error.stack || util.inspect(error); + process.stdout.write( + JSON.stringify({ + responseURL: xhr.responseUrl, + status: xhr.status, + statusText: xhr.statusText + }), + () => { + process.exit(0); + } + ); + } +}); From bda8a93673a4177b286ebd2557ca54407d5a4c15 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Oct 2022 00:28:39 +0200 Subject: [PATCH 13/40] #463@trivial: Continues on XMLHttpRequest implementation. --- package-lock.json | 372 +++--- packages/happy-dom/package.json | 3 +- .../happy-dom/src/event/IEventListener.ts | 2 +- packages/happy-dom/src/fetch/FetchHandler.ts | 14 +- packages/happy-dom/src/location/Location.ts | 2 +- .../happy-dom/src/location/RelativeURL.ts | 2 +- packages/happy-dom/src/location/URL.ts | 6 - .../happy-dom/src/location/URLSearchParams.ts | 6 - packages/happy-dom/src/window/IWindow.ts | 9 +- packages/happy-dom/src/window/Window.ts | 21 +- .../xml-http-request/XMLHttpReqeustUtility.ts | 13 - .../src/xml-http-request/XMLHttpRequest.ts | 1101 ++++++++--------- .../XMLHttpRequestEventTarget.ts | 16 +- .../XMLHttpRequestReadyStateEnum.ts | 9 + .../XMLHttpRequestSyncWorker.ts | 73 -- .../xml-http-request/XMLHttpRequestUpload.ts | 84 +- 16 files changed, 763 insertions(+), 970 deletions(-) delete mode 100644 packages/happy-dom/src/location/URL.ts delete mode 100644 packages/happy-dom/src/location/URLSearchParams.ts delete mode 100644 packages/happy-dom/src/xml-http-request/XMLHttpReqeustUtility.ts create mode 100644 packages/happy-dom/src/xml-http-request/XMLHttpRequestReadyStateEnum.ts delete mode 100644 packages/happy-dom/src/xml-http-request/XMLHttpRequestSyncWorker.ts diff --git a/package-lock.json b/package-lock.json index 3c541db1e..d32938e0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2152,9 +2152,9 @@ } }, "node_modules/@lit/reactive-element": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.3.2.tgz", - "integrity": "sha512-A2e18XzPMrIh35nhIdE4uoqRzoIpEU5vZYuQN4S3Ee1zkGdYC27DP12pewbw/RLgPHzaE4kx/YqxMzebOpm0dA==" + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.4.1.tgz", + "integrity": "sha512-qDv4851VFSaBWzpS02cXHclo40jsbAjRXnebNXpm0uVg32kCneZPo9RYVQtrTNICtZ+1wAYHu1ZtxWSWMbKrBw==" }, "node_modules/@mrmlnc/readdir-enhanced": { "version": "2.2.1", @@ -2634,9 +2634,9 @@ "integrity": "sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A==" }, "node_modules/@types/node-fetch": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.1.tgz", - "integrity": "sha512-oMqjURCaxoSIsHSr1E47QHzbmzNR5rK8McHuNb11BOM9cHcIK3Avy0s/b2JlXHoQGTYS3NsvWzV1M0iK7l0wbA==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz", + "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==", "dependencies": { "@types/node": "*", "form-data": "^3.0.0" @@ -2873,36 +2873,36 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.37.tgz", - "integrity": "sha512-81KhEjo7YAOh0vQJoSmAD68wLfYqJvoiD4ulyedzF+OEk/bk6/hx3fTNVfuzugIIaTrOx4PGx6pAiBRe5e9Zmg==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.40.tgz", + "integrity": "sha512-2Dc3Stk0J/VyQ4OUr2yEC53kU28614lZS+bnrCbFSAIftBJ40g/2yQzf4mPBiFuqguMB7hyHaujdgZAQ67kZYA==", "dependencies": { "@babel/parser": "^7.16.4", - "@vue/shared": "3.2.37", + "@vue/shared": "3.2.40", "estree-walker": "^2.0.2", "source-map": "^0.6.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.37.tgz", - "integrity": "sha512-yxJLH167fucHKxaqXpYk7x8z7mMEnXOw3G2q62FTkmsvNxu4FQSu5+3UMb+L7fjKa26DEzhrmCxAgFLLIzVfqQ==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.40.tgz", + "integrity": "sha512-OZCNyYVC2LQJy4H7h0o28rtk+4v+HMQygRTpmibGoG9wZyomQiS5otU7qo3Wlq5UfHDw2RFwxb9BJgKjVpjrQw==", "dependencies": { - "@vue/compiler-core": "3.2.37", - "@vue/shared": "3.2.37" + "@vue/compiler-core": "3.2.40", + "@vue/shared": "3.2.40" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.37.tgz", - "integrity": "sha512-+7i/2+9LYlpqDv+KTtWhOZH+pa8/HnX/905MdVmAcI/mPQOBwkHHIzrsEsucyOIZQYMkXUiTkmZq5am/NyXKkg==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.40.tgz", + "integrity": "sha512-tzqwniIN1fu1PDHC3CpqY/dPCfN/RN1thpBC+g69kJcrl7mbGiHKNwbA6kJ3XKKy8R6JLKqcpVugqN4HkeBFFg==", "dependencies": { "@babel/parser": "^7.16.4", - "@vue/compiler-core": "3.2.37", - "@vue/compiler-dom": "3.2.37", - "@vue/compiler-ssr": "3.2.37", - "@vue/reactivity-transform": "3.2.37", - "@vue/shared": "3.2.37", + "@vue/compiler-core": "3.2.40", + "@vue/compiler-dom": "3.2.40", + "@vue/compiler-ssr": "3.2.40", + "@vue/reactivity-transform": "3.2.40", + "@vue/shared": "3.2.40", "estree-walker": "^2.0.2", "magic-string": "^0.25.7", "postcss": "^8.1.10", @@ -2910,69 +2910,69 @@ } }, "node_modules/@vue/compiler-ssr": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.37.tgz", - "integrity": "sha512-7mQJD7HdXxQjktmsWp/J67lThEIcxLemz1Vb5I6rYJHR5vI+lON3nPGOH3ubmbvYGt8xEUaAr1j7/tIFWiEOqw==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.40.tgz", + "integrity": "sha512-80cQcgasKjrPPuKcxwuCx7feq+wC6oFl5YaKSee9pV3DNq+6fmCVwEEC3vvkf/E2aI76rIJSOYHsWSEIxK74oQ==", "dependencies": { - "@vue/compiler-dom": "3.2.37", - "@vue/shared": "3.2.37" + "@vue/compiler-dom": "3.2.40", + "@vue/shared": "3.2.40" } }, "node_modules/@vue/reactivity": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.37.tgz", - "integrity": "sha512-/7WRafBOshOc6m3F7plwzPeCu/RCVv9uMpOwa/5PiY1Zz+WLVRWiy0MYKwmg19KBdGtFWsmZ4cD+LOdVPcs52A==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.40.tgz", + "integrity": "sha512-N9qgGLlZmtUBMHF9xDT4EkD9RdXde1Xbveb+niWMXuHVWQP5BzgRmE3SFyUBBcyayG4y1lhoz+lphGRRxxK4RA==", "dependencies": { - "@vue/shared": "3.2.37" + "@vue/shared": "3.2.40" } }, "node_modules/@vue/reactivity-transform": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.37.tgz", - "integrity": "sha512-IWopkKEb+8qpu/1eMKVeXrK0NLw9HicGviJzhJDEyfxTR9e1WtpnnbYkJWurX6WwoFP0sz10xQg8yL8lgskAZg==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.40.tgz", + "integrity": "sha512-HQUCVwEaacq6fGEsg2NUuGKIhUveMCjOk8jGHqLXPI2w6zFoPrlQhwWEaINTv5kkZDXKEnCijAp+4gNEHG03yw==", "dependencies": { "@babel/parser": "^7.16.4", - "@vue/compiler-core": "3.2.37", - "@vue/shared": "3.2.37", + "@vue/compiler-core": "3.2.40", + "@vue/shared": "3.2.40", "estree-walker": "^2.0.2", "magic-string": "^0.25.7" } }, "node_modules/@vue/runtime-core": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.37.tgz", - "integrity": "sha512-JPcd9kFyEdXLl/i0ClS7lwgcs0QpUAWj+SKX2ZC3ANKi1U4DOtiEr6cRqFXsPwY5u1L9fAjkinIdB8Rz3FoYNQ==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.40.tgz", + "integrity": "sha512-U1+rWf0H8xK8aBUZhnrN97yoZfHbjgw/bGUzfgKPJl69/mXDuSg8CbdBYBn6VVQdR947vWneQBFzdhasyzMUKg==", "dependencies": { - "@vue/reactivity": "3.2.37", - "@vue/shared": "3.2.37" + "@vue/reactivity": "3.2.40", + "@vue/shared": "3.2.40" } }, "node_modules/@vue/runtime-dom": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.37.tgz", - "integrity": "sha512-HimKdh9BepShW6YozwRKAYjYQWg9mQn63RGEiSswMbW+ssIht1MILYlVGkAGGQbkhSh31PCdoUcfiu4apXJoPw==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.40.tgz", + "integrity": "sha512-AO2HMQ+0s2+MCec8hXAhxMgWhFhOPJ/CyRXnmTJ6XIOnJFLrH5Iq3TNwvVcODGR295jy77I6dWPj+wvFoSYaww==", "dependencies": { - "@vue/runtime-core": "3.2.37", - "@vue/shared": "3.2.37", + "@vue/runtime-core": "3.2.40", + "@vue/shared": "3.2.40", "csstype": "^2.6.8" } }, "node_modules/@vue/server-renderer": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.37.tgz", - "integrity": "sha512-kLITEJvaYgZQ2h47hIzPh2K3jG8c1zCVbp/o/bzQOyvzaKiCquKS7AaioPI28GNxIsE/zSx+EwWYsNxDCX95MA==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.40.tgz", + "integrity": "sha512-gtUcpRwrXOJPJ4qyBpU3EyxQa4EkV8I4f8VrDePcGCPe4O/hd0BPS7v9OgjIQob6Ap8VDz9G+mGTKazE45/95w==", "dependencies": { - "@vue/compiler-ssr": "3.2.37", - "@vue/shared": "3.2.37" + "@vue/compiler-ssr": "3.2.40", + "@vue/shared": "3.2.40" }, "peerDependencies": { - "vue": "3.2.37" + "vue": "3.2.40" } }, "node_modules/@vue/shared": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.37.tgz", - "integrity": "sha512-4rSJemR2NQIo9Klm1vabqWjD8rs/ZaJSzMxkMNeJS6lHiUjjUeYFbooN19NgFjztubEKh3WlZUeOLVdbbUWHsw==" + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.40.tgz", + "integrity": "sha512-0PLQ6RUtZM0vO3teRfzGi4ltLUO5aO+kLgwh4Um3THSR03rpQWLTuRCkuO5A41ITzwdWeKdPHtSARuPkoo5pCQ==" }, "node_modules/abab": { "version": "2.0.5", @@ -4651,7 +4651,7 @@ "node_modules/cpy/node_modules/to-regex-range": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", - "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==", "dependencies": { "is-number": "^3.0.0", "repeat-string": "^1.6.1" @@ -4704,9 +4704,9 @@ "dev": true }, "node_modules/csstype": { - "version": "2.6.20", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz", - "integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA==" + "version": "2.6.21", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", + "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==" }, "node_modules/dargs": { "version": "7.0.0", @@ -8584,9 +8584,9 @@ } }, "node_modules/jquery": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz", - "integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==" + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.1.tgz", + "integrity": "sha512-opJeO4nCucVnsjiXOE+/PcCgYw9Gwpvs/a6B1LL/lQhwWwpbVEVYDZ1FokFr8PRc7ghYlrFPuyHuiiDNTQxmcw==" }, "node_modules/js-tokens": { "version": "4.0.0", @@ -9041,28 +9041,28 @@ "dev": true }, "node_modules/lit": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/lit/-/lit-2.2.5.tgz", - "integrity": "sha512-Ln463c0xJZfzVxBcHddNvFQQ8Z22NK7KgNmrzwFF1iESHUud412RRExzepj18wpTbusgwoTnOYuoTpo9uyNBaQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/lit/-/lit-2.4.0.tgz", + "integrity": "sha512-fdgzxEtLrZFQU/BqTtxFQCLwlZd9bdat+ltzSFjvWkZrs7eBmeX0L5MHUMb3kYIkuS8Xlfnii/iI5klirF8/Xg==", "dependencies": { - "@lit/reactive-element": "^1.3.0", + "@lit/reactive-element": "^1.4.0", "lit-element": "^3.2.0", - "lit-html": "^2.2.0" + "lit-html": "^2.4.0" } }, "node_modules/lit-element": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.2.0.tgz", - "integrity": "sha512-HbE7yt2SnUtg5DCrWt028oaU4D5F4k/1cntAFHTkzY8ZIa8N0Wmu92PxSxucsQSOXlODFrICkQ5x/tEshKi13g==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.2.2.tgz", + "integrity": "sha512-6ZgxBR9KNroqKb6+htkyBwD90XGRiqKDHVrW/Eh0EZ+l+iC+u+v+w3/BA5NGi4nizAVHGYvQBHUDuSmLjPp7NQ==", "dependencies": { "@lit/reactive-element": "^1.3.0", "lit-html": "^2.2.0" } }, "node_modules/lit-html": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.2.5.tgz", - "integrity": "sha512-e56Y9V+RNA+SGYsWP2DGb/wad5Ccd3xUZYjmcmbeZcnc0wP4zFQRXeXn7W3bbfBekmHDK2dOnuYNYkg0bQjh/w==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.4.0.tgz", + "integrity": "sha512-G6qXu4JNUpY6aaF2VMfaszhO9hlWw0hOTRFDmuMheg/nDYGB+2RztUSOyrzALAbr8Nh0Y7qjhYkReh3rPnplVg==", "dependencies": { "@types/trusted-types": "^2.0.2" } @@ -11013,9 +11013,9 @@ } }, "node_modules/postcss": { - "version": "8.4.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz", - "integrity": "sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==", + "version": "8.4.17", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.17.tgz", + "integrity": "sha512-UNxNOLQydcOFi41yHNMcKRZ39NeXlr8AxGuZJsdub8vIb12fHzcq37DTU/QtbI6WLxNg2gF9Z+8qtRwTj1UI1Q==", "funding": [ { "type": "opencollective", @@ -11090,9 +11090,9 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "node_modules/promise": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/promise/-/promise-8.1.0.tgz", - "integrity": "sha512-W04AqnILOL/sPRXziNicCjSNRruLAuIHEOVBazepu0545DDNGYHz7ar9ZgZ1fMU8/MA4mVxp5rkBWRi6OXIy3Q==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.2.0.tgz", + "integrity": "sha512-+CMAlLHqwRYwBMXKCP+o8ns7DN+xHDUiI+0nArsiJ9y+kJVPLFxEaSw6Ha9s9H0tftxg2Yzl25wqj9G7m5wLZg==", "dependencies": { "asap": "~2.0.6" } @@ -12192,7 +12192,7 @@ "node_modules/snapdragon/node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "engines": { "node": ">=0.10.0" } @@ -12437,7 +12437,7 @@ "node_modules/static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", - "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "integrity": "sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==", "dependencies": { "define-property": "^0.2.5", "object-copy": "^0.1.0" @@ -12977,7 +12977,7 @@ "node_modules/to-object-path": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", - "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==", "dependencies": { "kind-of": "^3.0.2" }, @@ -13340,7 +13340,7 @@ "node_modules/unset-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", - "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "integrity": "sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==", "dependencies": { "has-value": "^0.3.1", "isobject": "^3.0.0" @@ -13403,7 +13403,7 @@ "node_modules/urix": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", - "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==", "deprecated": "Please see https://github.com/lydell/urix#deprecated" }, "node_modules/use": { @@ -13538,15 +13538,15 @@ "dev": true }, "node_modules/vue": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.37.tgz", - "integrity": "sha512-bOKEZxrm8Eh+fveCqS1/NkG/n6aMidsI6hahas7pa0w/l7jkbssJVsRhVDs07IdDq7h9KHswZOgItnwJAgtVtQ==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.40.tgz", + "integrity": "sha512-1mGHulzUbl2Nk3pfvI5aXYYyJUs1nm4kyvuz38u4xlQkLUn1i2R7nDbI4TufECmY8v1qNBHYy62bCaM+3cHP2A==", "dependencies": { - "@vue/compiler-dom": "3.2.37", - "@vue/compiler-sfc": "3.2.37", - "@vue/runtime-dom": "3.2.37", - "@vue/server-renderer": "3.2.37", - "@vue/shared": "3.2.37" + "@vue/compiler-dom": "3.2.40", + "@vue/compiler-sfc": "3.2.40", + "@vue/runtime-dom": "3.2.40", + "@vue/server-renderer": "3.2.40", + "@vue/shared": "3.2.40" } }, "node_modules/w3c-hr-time": { @@ -15664,9 +15664,9 @@ } }, "@lit/reactive-element": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.3.2.tgz", - "integrity": "sha512-A2e18XzPMrIh35nhIdE4uoqRzoIpEU5vZYuQN4S3Ee1zkGdYC27DP12pewbw/RLgPHzaE4kx/YqxMzebOpm0dA==" + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.4.1.tgz", + "integrity": "sha512-qDv4851VFSaBWzpS02cXHclo40jsbAjRXnebNXpm0uVg32kCneZPo9RYVQtrTNICtZ+1wAYHu1ZtxWSWMbKrBw==" }, "@mrmlnc/readdir-enhanced": { "version": "2.2.1", @@ -16104,9 +16104,9 @@ "integrity": "sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A==" }, "@types/node-fetch": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.1.tgz", - "integrity": "sha512-oMqjURCaxoSIsHSr1E47QHzbmzNR5rK8McHuNb11BOM9cHcIK3Avy0s/b2JlXHoQGTYS3NsvWzV1M0iK7l0wbA==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz", + "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==", "requires": { "@types/node": "*", "form-data": "^3.0.0" @@ -16254,36 +16254,36 @@ } }, "@vue/compiler-core": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.37.tgz", - "integrity": "sha512-81KhEjo7YAOh0vQJoSmAD68wLfYqJvoiD4ulyedzF+OEk/bk6/hx3fTNVfuzugIIaTrOx4PGx6pAiBRe5e9Zmg==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.40.tgz", + "integrity": "sha512-2Dc3Stk0J/VyQ4OUr2yEC53kU28614lZS+bnrCbFSAIftBJ40g/2yQzf4mPBiFuqguMB7hyHaujdgZAQ67kZYA==", "requires": { "@babel/parser": "^7.16.4", - "@vue/shared": "3.2.37", + "@vue/shared": "3.2.40", "estree-walker": "^2.0.2", "source-map": "^0.6.1" } }, "@vue/compiler-dom": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.37.tgz", - "integrity": "sha512-yxJLH167fucHKxaqXpYk7x8z7mMEnXOw3G2q62FTkmsvNxu4FQSu5+3UMb+L7fjKa26DEzhrmCxAgFLLIzVfqQ==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.40.tgz", + "integrity": "sha512-OZCNyYVC2LQJy4H7h0o28rtk+4v+HMQygRTpmibGoG9wZyomQiS5otU7qo3Wlq5UfHDw2RFwxb9BJgKjVpjrQw==", "requires": { - "@vue/compiler-core": "3.2.37", - "@vue/shared": "3.2.37" + "@vue/compiler-core": "3.2.40", + "@vue/shared": "3.2.40" } }, "@vue/compiler-sfc": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.37.tgz", - "integrity": "sha512-+7i/2+9LYlpqDv+KTtWhOZH+pa8/HnX/905MdVmAcI/mPQOBwkHHIzrsEsucyOIZQYMkXUiTkmZq5am/NyXKkg==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.40.tgz", + "integrity": "sha512-tzqwniIN1fu1PDHC3CpqY/dPCfN/RN1thpBC+g69kJcrl7mbGiHKNwbA6kJ3XKKy8R6JLKqcpVugqN4HkeBFFg==", "requires": { "@babel/parser": "^7.16.4", - "@vue/compiler-core": "3.2.37", - "@vue/compiler-dom": "3.2.37", - "@vue/compiler-ssr": "3.2.37", - "@vue/reactivity-transform": "3.2.37", - "@vue/shared": "3.2.37", + "@vue/compiler-core": "3.2.40", + "@vue/compiler-dom": "3.2.40", + "@vue/compiler-ssr": "3.2.40", + "@vue/reactivity-transform": "3.2.40", + "@vue/shared": "3.2.40", "estree-walker": "^2.0.2", "magic-string": "^0.25.7", "postcss": "^8.1.10", @@ -16291,66 +16291,66 @@ } }, "@vue/compiler-ssr": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.37.tgz", - "integrity": "sha512-7mQJD7HdXxQjktmsWp/J67lThEIcxLemz1Vb5I6rYJHR5vI+lON3nPGOH3ubmbvYGt8xEUaAr1j7/tIFWiEOqw==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.40.tgz", + "integrity": "sha512-80cQcgasKjrPPuKcxwuCx7feq+wC6oFl5YaKSee9pV3DNq+6fmCVwEEC3vvkf/E2aI76rIJSOYHsWSEIxK74oQ==", "requires": { - "@vue/compiler-dom": "3.2.37", - "@vue/shared": "3.2.37" + "@vue/compiler-dom": "3.2.40", + "@vue/shared": "3.2.40" } }, "@vue/reactivity": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.37.tgz", - "integrity": "sha512-/7WRafBOshOc6m3F7plwzPeCu/RCVv9uMpOwa/5PiY1Zz+WLVRWiy0MYKwmg19KBdGtFWsmZ4cD+LOdVPcs52A==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.40.tgz", + "integrity": "sha512-N9qgGLlZmtUBMHF9xDT4EkD9RdXde1Xbveb+niWMXuHVWQP5BzgRmE3SFyUBBcyayG4y1lhoz+lphGRRxxK4RA==", "requires": { - "@vue/shared": "3.2.37" + "@vue/shared": "3.2.40" } }, "@vue/reactivity-transform": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.37.tgz", - "integrity": "sha512-IWopkKEb+8qpu/1eMKVeXrK0NLw9HicGviJzhJDEyfxTR9e1WtpnnbYkJWurX6WwoFP0sz10xQg8yL8lgskAZg==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.40.tgz", + "integrity": "sha512-HQUCVwEaacq6fGEsg2NUuGKIhUveMCjOk8jGHqLXPI2w6zFoPrlQhwWEaINTv5kkZDXKEnCijAp+4gNEHG03yw==", "requires": { "@babel/parser": "^7.16.4", - "@vue/compiler-core": "3.2.37", - "@vue/shared": "3.2.37", + "@vue/compiler-core": "3.2.40", + "@vue/shared": "3.2.40", "estree-walker": "^2.0.2", "magic-string": "^0.25.7" } }, "@vue/runtime-core": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.37.tgz", - "integrity": "sha512-JPcd9kFyEdXLl/i0ClS7lwgcs0QpUAWj+SKX2ZC3ANKi1U4DOtiEr6cRqFXsPwY5u1L9fAjkinIdB8Rz3FoYNQ==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.40.tgz", + "integrity": "sha512-U1+rWf0H8xK8aBUZhnrN97yoZfHbjgw/bGUzfgKPJl69/mXDuSg8CbdBYBn6VVQdR947vWneQBFzdhasyzMUKg==", "requires": { - "@vue/reactivity": "3.2.37", - "@vue/shared": "3.2.37" + "@vue/reactivity": "3.2.40", + "@vue/shared": "3.2.40" } }, "@vue/runtime-dom": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.37.tgz", - "integrity": "sha512-HimKdh9BepShW6YozwRKAYjYQWg9mQn63RGEiSswMbW+ssIht1MILYlVGkAGGQbkhSh31PCdoUcfiu4apXJoPw==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.40.tgz", + "integrity": "sha512-AO2HMQ+0s2+MCec8hXAhxMgWhFhOPJ/CyRXnmTJ6XIOnJFLrH5Iq3TNwvVcODGR295jy77I6dWPj+wvFoSYaww==", "requires": { - "@vue/runtime-core": "3.2.37", - "@vue/shared": "3.2.37", + "@vue/runtime-core": "3.2.40", + "@vue/shared": "3.2.40", "csstype": "^2.6.8" } }, "@vue/server-renderer": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.37.tgz", - "integrity": "sha512-kLITEJvaYgZQ2h47hIzPh2K3jG8c1zCVbp/o/bzQOyvzaKiCquKS7AaioPI28GNxIsE/zSx+EwWYsNxDCX95MA==", + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.40.tgz", + "integrity": "sha512-gtUcpRwrXOJPJ4qyBpU3EyxQa4EkV8I4f8VrDePcGCPe4O/hd0BPS7v9OgjIQob6Ap8VDz9G+mGTKazE45/95w==", "requires": { - "@vue/compiler-ssr": "3.2.37", - "@vue/shared": "3.2.37" + "@vue/compiler-ssr": "3.2.40", + "@vue/shared": "3.2.40" } }, "@vue/shared": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.37.tgz", - "integrity": "sha512-4rSJemR2NQIo9Klm1vabqWjD8rs/ZaJSzMxkMNeJS6lHiUjjUeYFbooN19NgFjztubEKh3WlZUeOLVdbbUWHsw==" + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.40.tgz", + "integrity": "sha512-0PLQ6RUtZM0vO3teRfzGi4ltLUO5aO+kLgwh4Um3THSR03rpQWLTuRCkuO5A41ITzwdWeKdPHtSARuPkoo5pCQ==" }, "abab": { "version": "2.0.5", @@ -17658,7 +17658,7 @@ "to-regex-range": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", - "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==", "requires": { "is-number": "^3.0.0", "repeat-string": "^1.6.1" @@ -17706,9 +17706,9 @@ } }, "csstype": { - "version": "2.6.20", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz", - "integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA==" + "version": "2.6.21", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", + "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==" }, "dargs": { "version": "7.0.0", @@ -20705,9 +20705,9 @@ } }, "jquery": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz", - "integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==" + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.1.tgz", + "integrity": "sha512-opJeO4nCucVnsjiXOE+/PcCgYw9Gwpvs/a6B1LL/lQhwWwpbVEVYDZ1FokFr8PRc7ghYlrFPuyHuiiDNTQxmcw==" }, "js-tokens": { "version": "4.0.0", @@ -21077,28 +21077,28 @@ "dev": true }, "lit": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/lit/-/lit-2.2.5.tgz", - "integrity": "sha512-Ln463c0xJZfzVxBcHddNvFQQ8Z22NK7KgNmrzwFF1iESHUud412RRExzepj18wpTbusgwoTnOYuoTpo9uyNBaQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/lit/-/lit-2.4.0.tgz", + "integrity": "sha512-fdgzxEtLrZFQU/BqTtxFQCLwlZd9bdat+ltzSFjvWkZrs7eBmeX0L5MHUMb3kYIkuS8Xlfnii/iI5klirF8/Xg==", "requires": { - "@lit/reactive-element": "^1.3.0", + "@lit/reactive-element": "^1.4.0", "lit-element": "^3.2.0", - "lit-html": "^2.2.0" + "lit-html": "^2.4.0" } }, "lit-element": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.2.0.tgz", - "integrity": "sha512-HbE7yt2SnUtg5DCrWt028oaU4D5F4k/1cntAFHTkzY8ZIa8N0Wmu92PxSxucsQSOXlODFrICkQ5x/tEshKi13g==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.2.2.tgz", + "integrity": "sha512-6ZgxBR9KNroqKb6+htkyBwD90XGRiqKDHVrW/Eh0EZ+l+iC+u+v+w3/BA5NGi4nizAVHGYvQBHUDuSmLjPp7NQ==", "requires": { "@lit/reactive-element": "^1.3.0", "lit-html": "^2.2.0" } }, "lit-html": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.2.5.tgz", - "integrity": "sha512-e56Y9V+RNA+SGYsWP2DGb/wad5Ccd3xUZYjmcmbeZcnc0wP4zFQRXeXn7W3bbfBekmHDK2dOnuYNYkg0bQjh/w==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.4.0.tgz", + "integrity": "sha512-G6qXu4JNUpY6aaF2VMfaszhO9hlWw0hOTRFDmuMheg/nDYGB+2RztUSOyrzALAbr8Nh0Y7qjhYkReh3rPnplVg==", "requires": { "@types/trusted-types": "^2.0.2" } @@ -22610,9 +22610,9 @@ "integrity": "sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==" }, "postcss": { - "version": "8.4.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz", - "integrity": "sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==", + "version": "8.4.17", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.17.tgz", + "integrity": "sha512-UNxNOLQydcOFi41yHNMcKRZ39NeXlr8AxGuZJsdub8vIb12fHzcq37DTU/QtbI6WLxNg2gF9Z+8qtRwTj1UI1Q==", "requires": { "nanoid": "^3.3.4", "picocolors": "^1.0.0", @@ -22656,9 +22656,9 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "promise": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/promise/-/promise-8.1.0.tgz", - "integrity": "sha512-W04AqnILOL/sPRXziNicCjSNRruLAuIHEOVBazepu0545DDNGYHz7ar9ZgZ1fMU8/MA4mVxp5rkBWRi6OXIy3Q==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.2.0.tgz", + "integrity": "sha512-+CMAlLHqwRYwBMXKCP+o8ns7DN+xHDUiI+0nArsiJ9y+kJVPLFxEaSw6Ha9s9H0tftxg2Yzl25wqj9G7m5wLZg==", "requires": { "asap": "~2.0.6" } @@ -23464,7 +23464,7 @@ "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" } } }, @@ -23700,7 +23700,7 @@ "static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", - "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "integrity": "sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==", "requires": { "define-property": "^0.2.5", "object-copy": "^0.1.0" @@ -24118,7 +24118,7 @@ "to-object-path": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", - "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==", "requires": { "kind-of": "^3.0.2" }, @@ -24382,7 +24382,7 @@ "unset-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", - "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "integrity": "sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==", "requires": { "has-value": "^0.3.1", "isobject": "^3.0.0" @@ -24433,7 +24433,7 @@ "urix": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", - "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=" + "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==" }, "use": { "version": "3.1.1", @@ -24553,15 +24553,15 @@ "dev": true }, "vue": { - "version": "3.2.37", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.37.tgz", - "integrity": "sha512-bOKEZxrm8Eh+fveCqS1/NkG/n6aMidsI6hahas7pa0w/l7jkbssJVsRhVDs07IdDq7h9KHswZOgItnwJAgtVtQ==", - "requires": { - "@vue/compiler-dom": "3.2.37", - "@vue/compiler-sfc": "3.2.37", - "@vue/runtime-dom": "3.2.37", - "@vue/server-renderer": "3.2.37", - "@vue/shared": "3.2.37" + "version": "3.2.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.40.tgz", + "integrity": "sha512-1mGHulzUbl2Nk3pfvI5aXYYyJUs1nm4kyvuz38u4xlQkLUn1i2R7nDbI4TufECmY8v1qNBHYy62bCaM+3cHP2A==", + "requires": { + "@vue/compiler-dom": "3.2.40", + "@vue/compiler-sfc": "3.2.40", + "@vue/runtime-dom": "3.2.40", + "@vue/server-renderer": "3.2.40", + "@vue/shared": "3.2.40" } }, "w3c-hr-time": { diff --git a/packages/happy-dom/package.json b/packages/happy-dom/package.json index 8c6c8e077..d69160d59 100644 --- a/packages/happy-dom/package.json +++ b/packages/happy-dom/package.json @@ -53,7 +53,8 @@ "whatwg-encoding": "^2.0.0", "whatwg-mimetype": "^3.0.0", "webidl-conversions": "^7.0.0", - "css.escape": "^1.5.1" + "css.escape": "^1.5.1", + "xmlhttprequest-ssl": "^1.6.3" }, "devDependencies": { "@types/he": "^1.1.2", diff --git a/packages/happy-dom/src/event/IEventListener.ts b/packages/happy-dom/src/event/IEventListener.ts index 0c9e5cbee..0423fec60 100644 --- a/packages/happy-dom/src/event/IEventListener.ts +++ b/packages/happy-dom/src/event/IEventListener.ts @@ -7,7 +7,7 @@ export default interface IEventListener { /** * Handles event. * - * @param event + * @param event Event. */ handleEvent(event: Event): void; } diff --git a/packages/happy-dom/src/fetch/FetchHandler.ts b/packages/happy-dom/src/fetch/FetchHandler.ts index f4da6a206..3b4e1e4c4 100644 --- a/packages/happy-dom/src/fetch/FetchHandler.ts +++ b/packages/happy-dom/src/fetch/FetchHandler.ts @@ -20,17 +20,19 @@ export default class FetchHandler { public static fetch(document: IDocument, url: string, init?: IRequestInit): Promise { // We want to only load NodeFetch when it is needed to improve performance and not have direct dependencies to server side packages. const taskManager = document.defaultView.happyDOM.asyncTaskManager; + const requestInit = { ...init, headers: { ...init?.headers } }; + // We need set referer to solve anti-hotlinking. // And the browser will set the referer to the origin of the page. - if (init) { - if (!init.headers['referer']) { - init.headers['referer'] = document.defaultView.location.origin; - } - } + requestInit.headers['referer'] = document.defaultView.location.origin; + + requestInit.headers['user-agent'] = document.defaultView.navigator.userAgent; + requestInit.headers['cookie'] = document.defaultView.document.cookie; + return new Promise((resolve, reject) => { const taskID = taskManager.startTask(); - NodeFetch(RelativeURL.getAbsoluteURL(document.defaultView.location, url).href, init) + NodeFetch(RelativeURL.getAbsoluteURL(document.defaultView.location, url).href, requestInit) .then((response) => { if (taskManager.getTaskCount() === 0) { reject(new Error('Failed to complete fetch request. Task was canceled.')); diff --git a/packages/happy-dom/src/location/Location.ts b/packages/happy-dom/src/location/Location.ts index 0e16a3671..05898745b 100644 --- a/packages/happy-dom/src/location/Location.ts +++ b/packages/happy-dom/src/location/Location.ts @@ -1,4 +1,4 @@ -import URL from './URL'; +import { URL } from 'url'; /** * diff --git a/packages/happy-dom/src/location/RelativeURL.ts b/packages/happy-dom/src/location/RelativeURL.ts index 53a04ab5f..3308685c2 100644 --- a/packages/happy-dom/src/location/RelativeURL.ts +++ b/packages/happy-dom/src/location/RelativeURL.ts @@ -1,5 +1,5 @@ import Location from './Location'; -import URL from './URL'; +import { URL } from 'url'; /** * Helper class for getting the URL relative to a Location object. diff --git a/packages/happy-dom/src/location/URL.ts b/packages/happy-dom/src/location/URL.ts deleted file mode 100644 index 2d52a7c47..000000000 --- a/packages/happy-dom/src/location/URL.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { URL as Url } from 'url'; - -/** - * - */ -export default class URL extends Url {} diff --git a/packages/happy-dom/src/location/URLSearchParams.ts b/packages/happy-dom/src/location/URLSearchParams.ts deleted file mode 100644 index fbfd6dbba..000000000 --- a/packages/happy-dom/src/location/URLSearchParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { URLSearchParams as UrlSearchParams } from 'url'; - -/** - * - */ -export default class URLSearchParams extends UrlSearchParams {} diff --git a/packages/happy-dom/src/window/IWindow.ts b/packages/happy-dom/src/window/IWindow.ts index 88fa0b7aa..4dd4c9898 100644 --- a/packages/happy-dom/src/window/IWindow.ts +++ b/packages/happy-dom/src/window/IWindow.ts @@ -36,8 +36,7 @@ import AnimationEvent from '../event/events/AnimationEvent'; import KeyboardEvent from '../event/events/KeyboardEvent'; import ProgressEvent from '../event/events/ProgressEvent'; import EventTarget from '../event/EventTarget'; -import URL from '../location/URL'; -import URLSearchParams from '../location/URLSearchParams'; +import { URL, URLSearchParams } from 'url'; import Location from '../location/Location'; import MutationObserver from '../mutation-observer/MutationObserver'; import DOMParser from '../dom-parser/DOMParser'; @@ -83,6 +82,9 @@ import IRequestInit from '../fetch/IRequestInit'; import IResponse from '../fetch/IResponse'; import Range from '../range/Range'; import MediaQueryList from '../match-media/MediaQueryList'; +import XMLHttpRequest from '../xml-http-request/XMLHttpRequest'; +import XMLHttpRequestUpload from '../xml-http-request/XMLHttpRequestUpload'; +import XMLHttpRequestEventTarget from '../xml-http-request/XMLHttpRequestEventTarget'; import DOMRect from '../nodes/element/DOMRect'; import Window from './Window'; import { Performance } from 'perf_hooks'; @@ -179,6 +181,9 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { readonly Response: { new (body?: unknown | null, init?: IResponseInit): IResponse }; readonly Range: typeof Range; readonly DOMRect: typeof DOMRect; + readonly XMLHttpRequest: typeof XMLHttpRequest; + readonly XMLHttpRequestUpload: typeof XMLHttpRequestUpload; + readonly XMLHttpRequestEventTarget: typeof XMLHttpRequestEventTarget; // Events onload: (event: Event) => void; diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index 46aa1fc4f..28366b2d0 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -36,8 +36,7 @@ import AnimationEvent from '../event/events/AnimationEvent'; import KeyboardEvent from '../event/events/KeyboardEvent'; import ProgressEvent from '../event/events/ProgressEvent'; import EventTarget from '../event/EventTarget'; -import URL from '../location/URL'; -import URLSearchParams from '../location/URLSearchParams'; +import { URL, URLSearchParams } from 'url'; import Location from '../location/Location'; import NonImplementedEventTypes from '../event/NonImplementedEventTypes'; import MutationObserver from '../mutation-observer/MutationObserver'; @@ -94,9 +93,9 @@ import VMGlobalPropertyScript from './VMGlobalPropertyScript'; import * as PerfHooks from 'perf_hooks'; import VM from 'vm'; import { Buffer } from 'buffer'; -import XMLHttpRequest from '../xml-http-request/XMLHttpRequest'; +import XMLHttpRequestImplementation from '../xml-http-request/XMLHttpRequest'; import XMLHttpRequestUpload from '../xml-http-request/XMLHttpRequestUpload'; -import { XMLHttpRequestEventTarget } from '../xml-http-request/XMLHttpRequestEventTarget'; +import XMLHttpRequestEventTarget from '../xml-http-request/XMLHttpRequestEventTarget'; import Base64 from '../base64/Base64'; import IDocument from '../nodes/document/IDocument'; @@ -204,6 +203,9 @@ export default class Window extends EventTarget implements IWindow { public readonly Response: { new (body?: NodeJS.ReadableStream | null, init?: IResponseInit): IResponse; }; + public readonly XMLHttpRequestUpload = XMLHttpRequestUpload; + public readonly XMLHttpRequestEventTarget = XMLHttpRequestEventTarget; + public readonly XMLHttpRequest; public readonly DOMParser; public readonly Range; public readonly FileReader; @@ -233,10 +235,6 @@ export default class Window extends EventTarget implements IWindow { public readonly localStorage = new Storage(); public readonly performance = PerfHooks.performance; - public XMLHttpRequest = XMLHttpRequest; - public XMLHttpRequestUpload = XMLHttpRequestUpload; - public XMLHttpRequestEventTarget = XMLHttpRequestEventTarget; - // Node.js Globals public ArrayBuffer; public Boolean; @@ -345,7 +343,6 @@ export default class Window extends EventTarget implements IWindow { } } - XMLHttpRequest._defaultView = this; HTMLDocument._defaultView = this; const document = new HTMLDocument(); @@ -361,6 +358,7 @@ export default class Window extends EventTarget implements IWindow { FileReaderImplementation._ownerDocument = document; DOMParserImplementation._ownerDocument = document; RangeImplementation._ownerDocument = document; + XMLHttpRequestImplementation._ownerDocument = document; /* eslint-disable jsdoc/require-jsdoc */ class Response extends ResponseImplementation { @@ -378,10 +376,9 @@ export default class Window extends EventTarget implements IWindow { class DOMParser extends DOMParserImplementation { public static _ownerDocument: IDocument = document; } - class Range extends RangeImplementation { + class XMLHttpRequest extends XMLHttpRequestImplementation { public static _ownerDocument: IDocument = document; } - /* eslint-enable jsdoc/require-jsdoc */ this.Response = Response; @@ -389,7 +386,7 @@ export default class Window extends EventTarget implements IWindow { this.Image = Image; this.FileReader = FileReader; this.DOMParser = DOMParser; - this.Range = Range; + this.XMLHttpRequest = XMLHttpRequest; this._setupVMContext(); diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpReqeustUtility.ts b/packages/happy-dom/src/xml-http-request/XMLHttpReqeustUtility.ts deleted file mode 100644 index 17ee0ac44..000000000 --- a/packages/happy-dom/src/xml-http-request/XMLHttpReqeustUtility.ts +++ /dev/null @@ -1,13 +0,0 @@ -const NodeVersion = process.version.replace('v', '').split('.'); - -export interface IXMLHttpRequestOptions { - anon?: boolean; -} - -export const MajorNodeVersion = Number.parseInt(NodeVersion[0]); -export const copyToArrayBuffer = (buffer: Buffer, offset?: number): ArrayBuffer => { - const arrayBuffer = new ArrayBuffer(buffer.length); - const view = new Uint8Array(arrayBuffer); - view.set(buffer, offset || 0); - return arrayBuffer; -}; diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 8e40e19c8..ad88299e8 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -1,696 +1,653 @@ -import * as http from 'http'; -import { - Agent as HttpAgent, - ClientRequest, - IncomingMessage, - RequestOptions as RequestOptionsHttp -} from 'http'; -import * as https from 'https'; -import { Agent as HttpsAgent } from 'https'; -import ProgressEvent from '../event/events/ProgressEvent'; -import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum'; -import { ProgressEventListener, XMLHttpRequestEventTarget } from './XMLHttpRequestEventTarget'; -import XMLHttpRequestUpload from './XMLHttpRequestUpload'; -import DOMException from '../exception/DOMException'; -import IWindow from '../window/IWindow'; -import URL from '../location/URL'; +import FS from 'fs'; +import { URL } from 'url'; +import ChildProcess from 'child_process'; +import HTTP from 'http'; +import HTTPS from 'https'; +import XMLHttpRequestEventTarget from './XMLHttpRequestEventTarget'; +import XMLHttpRequestReadyStateEnum from './XMLHttpRequestReadyStateEnum'; +import Event from '../event/Event'; +import IDocument from '../nodes/document/IDocument'; import RelativeURL from '../location/RelativeURL'; -import Blob from '../file/Blob'; -import { - copyToArrayBuffer, - MajorNodeVersion, - IXMLHttpRequestOptions -} from './XMLHttpReqeustUtility'; -import { spawnSync } from "child_process"; -const SyncWorkerFile = require.resolve ? require.resolve('./XMLHttpRequestSyncWorker') : null; +import XMLHttpRequestUpload from './XMLHttpRequestUpload'; + +const SSL_CERT = `-----BEGIN CERTIFICATE----- +MIIDYzCCAkugAwIBAgIUJRKB/H66hpet1VfUlm0CiXqePA4wDQYJKoZIhvcNAQEL +BQAwQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVNYWxt +bzESMBAGA1UECgwJSGFwcHkgRE9NMB4XDTIyMTAxMTIyMDM0OVoXDTMyMTAwODIy +MDM0OVowQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVN +YWxtbzESMBAGA1UECgwJSGFwcHkgRE9NMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAqerQSQEg/SxVxRiwlItithr5e5EMZo1nsqt/xOxagbmpW3IEmt0j +bpbH7iEF4DDEo7KAOwUCOwVWeFxRoag8lG2ax48wrgjlCna45XDn0Xeg1ARajL04 +gs46HZ0VrzIloVGfln0zgt/Vum5BNqs9Oc5fQoBmoP3cAn3dn4ZVcP0AKthtcyPl +q2DuNRN0PV0D2RtMSiAy9l1Ko6N5x+sAeClDyOL+sTDLngZBVeZyOKt9Id15S8Zt +XtA6VMgHnnF3jChn7pag77rsd/y5iANAVNZYqRl+Eg7xaDcsvbgH46UBOrBcB39Q +tTh5Mtjoxep5e3ZDFG+kQ1HUE+iz5O5n0wIDAQABo1MwUTAdBgNVHQ4EFgQU69s9 +YSobG/m2SN4L/7zTaF7iDbwwHwYDVR0jBBgwFoAU69s9YSobG/m2SN4L/7zTaF7i +DbwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAi/WUXx2oal8L +YnPlIuKfh49n/K18wXSYG//oFYwxfVxqpYH8hUiXVm/GUcXCxS++hUkaKLqXmH9q +MKJiCrZr3vS+2nsBKopkICu/TLdROl0sAI9lByfnEbfSAzjxe1IWJdK8NdY0y5m5 +9pEr/URVIAp/CxrneyASb4q0Jg5To3FR7vYc+2X6wZn0MundKMg6Dp9/A37jiF3l +Tt/EJp299YZcsUzh+LnRuggRjnoOVu1aLcLFlaUiwZfy9m8mLG6B/mdW/qNzNMh9 +Oqvg1zfGdpz/4D/2UUUBn6pq1vbsoAaF3OesoA3mfDcegDf/H9woJlpT0Wql+e68 +Y3FblSokcA== +-----END CERTIFICATE-----`; + +const SSL_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCp6tBJASD9LFXF +GLCUi2K2Gvl7kQxmjWeyq3/E7FqBualbcgSa3SNulsfuIQXgMMSjsoA7BQI7BVZ4 +XFGhqDyUbZrHjzCuCOUKdrjlcOfRd6DUBFqMvTiCzjodnRWvMiWhUZ+WfTOC39W6 +bkE2qz05zl9CgGag/dwCfd2fhlVw/QAq2G1zI+WrYO41E3Q9XQPZG0xKIDL2XUqj +o3nH6wB4KUPI4v6xMMueBkFV5nI4q30h3XlLxm1e0DpUyAeecXeMKGfulqDvuux3 +/LmIA0BU1lipGX4SDvFoNyy9uAfjpQE6sFwHf1C1OHky2OjF6nl7dkMUb6RDUdQT +6LPk7mfTAgMBAAECggEAKkwTkTjAt4UjzK56tl+EMQTB+ep/hb/JgoaChci4Nva6 +m9LkJpDJ0yuhlTuPNOGu8XjrxsVWas7HWarRf0Zb3i7yip6wZYI9Ub+AA015x4DZ +/i0fRU2NFbK0cM67qSL4jxG8gj+kZP3HPGNZxHwX/53JxMolwgmvjMc8NgvAlSFd +NnV9h4xtbhUh1NGS5zmP3iU2rwnE8JrIEzwy6axLom7nekAgkdcbAr0UoBs8gcgH +aYNhU4Gz3tGcZZ0IXAfT/bJIH1Ko8AGv4pssWc3BXcmmNdm/+kzvHIxEIV7Qegmo +XG1ZyZCyD/0b4/3e8ySDBEDqwR+HeyTW2isWG2agAQKBgQDp44aTwr3dkIXY30xv +FPfUOipg/B49dWnffYJ9MWc1FT9ijNPAngWSk0EIiEQIazICcUBI4Yji6/KeyqLJ +GdLpDi1CkKqtyh73mjELinYp3EUQgEa77aQogGa2+nMOVfu+O5CtloUrv/A18jX3 ++VEyaEASK0fWmnSI0OdlxQHIAQKBgQC5+xOls2F3MlKASvWRLlnW1wHqlDTtVoYg +5Nh8syZH4Ci2UH8tON3A5/7SWNM0t1cgV6Cw4zW8Z2spgIT/W0iYYrQ4hHL1xdCu ++CxL1km4Gy8Uwpsd+KdFahFqF/XTmLzW0HXLxWSK0fTwmdV0SFrKF3MXfTCU2AeZ +jJoMFb6P0wKBgQC3Odw6s0vkYAzLGhuZxfZkVvDOK5RRF0NKpttr0iEFL9EJFkPo +2KKK8jr3QTDy229BBJGUxsJi6u6VwS8HlehpVQbV59kd7oKV/EBBx0XMg1fDlopT +PNbmN7i/zbIG4AsoOyebJZjL7kBzMn1e9vzKHWtcEHXlw/hZGja8vjooAQKBgAeg +xK2HLfg1mCyq5meN/yFQsENu0LzrT5UJzddPgcJw7zqLEqxIKNBAs7Ls8by3yFsL +PQwERa/0jfCl1M6kb9XQNpQa2pw6ANUsWKTDpUJn2wZ+9N3F1RaDwzMWyH5lRVmK +M0qoTfdjpSg5Jwgd75taWt4bxGJWeflSSv8z5R0BAoGAWL8c527AbeBvx2tOYKkD +2TFranvANNcoMrbeviZSkkGvMNDP3p8b6juJwXOIeWNr8q4vFgCzLmq6d1/9gYm2 +3XJwwyD0LKlqzkBrrKU47qrnmMosUrIRlrAzd3HbShOptxc6Iz2apSaUDKGKXkaw +gl5OpEjeliU7Mus0BVS858g= +-----END PRIVATE KEY-----`; + +// These headers are not user setable. +// The following are allowed but banned in the spec: +// * User-agent +const FORBIDDEN_REQUEST_HEADERS = [ + 'accept-charset', + 'accept-encoding', + 'access-control-request-headers', + 'access-control-request-method', + 'connection', + 'content-length', + 'content-transfer-encoding', + 'cookie', + 'cookie2', + 'date', + 'expect', + 'host', + 'keep-alive', + 'origin', + 'referer', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'via' +]; + +// These request methods are not allowed +const FORBIDDEN_REQUEST_METHODS = ['TRACE', 'TRACK', 'CONNECT']; /** - * References: https://github.com/souldreamer/xhr2-cookies. + * XMLHttpRequest. + * + * Based on: + * https://github.com/mjwwit/node-XMLHttpRequest/blob/master/lib/XMLHttpRequest.js */ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { - public static readonly UNSENT = 0; - public static readonly OPENED = 1; - public static readonly HEADERS_RECEIVED = 2; - public static readonly LOADING = 3; - public static readonly DONE = 4; - - public static _defaultView: IWindow; - - public onreadystatechange: ProgressEventListener | null = null; - public readyState: number = XMLHttpRequest.UNSENT; - - public response: string | ArrayBuffer | Buffer | object | null = null; + // Owner document is set by a sub-class in the Window constructor + public static _ownerDocument: IDocument = null; + + // Constants + public static UNSENT = XMLHttpRequestReadyStateEnum.unsent; + public static OPENED = XMLHttpRequestReadyStateEnum.opened; + public static HEADERS_RECEIVED = XMLHttpRequestReadyStateEnum.headersRecieved; + public static LOADING = XMLHttpRequestReadyStateEnum.loading; + public static DONE = XMLHttpRequestReadyStateEnum.done; + + // Public properties + public readyState: XMLHttpRequestReadyStateEnum = XMLHttpRequestReadyStateEnum.unsent; public responseText = ''; - public responseType = ''; - public status = 0; - public statusText = ''; - public timeout = 0; - public upload = new XMLHttpRequestUpload(); - public responseUrl = ''; - public withCredentials = false; - - public nodejsHttpAgent: HttpAgent = http.globalAgent; - public nodejsHttpsAgent: HttpsAgent = https.globalAgent; - - private readonly anonymous: boolean; - private method: string | null = null; - private url: URL | null = null; - private auth: string | null = null; - private body: string | Buffer | ArrayBuffer | ArrayBufferView; - private sync = false; - private headers: { [header: string]: string } = {}; - private loweredHeaders: { [lowercaseHeader: string]: string } = {}; - private mimeOverride: string | null = null; // TODO: is type right? - private _request: ClientRequest | null = null; - private _response: IncomingMessage | null = null; - // @ts-ignore - private _error: Error | string | null = null; - private responseParts: Buffer[] | null = null; - private responseHeaders: { [lowercaseHeader: string]: string } | null = null; - private loadedBytes = 0; - private totalBytes = 0; - private lengthComputable = false; - - private restrictedMethods = { CONNECT: true, TRACE: true, TRACK: true }; - private restrictedHeaders = { - 'accept-charset': true, - 'accept-encoding': true, - 'access-control-request-headers': true, - 'access-control-request-method': true, - connection: true, - 'content-length': true, - cookie: true, - cookie2: true, - date: true, - dnt: true, - expect: true, - host: true, - 'keep-alive': true, - origin: true, - referer: true, - te: true, - trailer: true, - 'transfer-encoding': true, - upgrade: true, - 'user-agent': true, - via: true + public responseXML = ''; + public status: number = null; + public statusText: string = null; + public upload: XMLHttpRequestUpload = new XMLHttpRequestUpload(); + + // Private properties + private readonly _ownerDocument: IDocument = null; + private _request = null; + private _response = null; + private _requestHeaders = {}; + private _sendFlag = false; + private _errorFlag = false; + private _abortedFlag = false; + private _settings: { + method: string; + url: string; + async: boolean; + user: string; + password: string; + } = { + method: null, + url: null, + async: true, + user: null, + password: null }; - private privateHeaders = { 'set-cookie': true, 'set-cookie2': true }; /** - * News a request. - * - * @param options + * Constructor. */ - constructor(options: IXMLHttpRequestOptions = {}) { + constructor() { super(); - this.anonymous = options.anon || false; + this._ownerDocument = XMLHttpRequest._ownerDocument; } /** - * Initializes a newly-created request, or re-initializes an existing one. + * Opens the connection. * - * @param method The HTTP request method to use. - * @param url The URL to request. - * @param async Whether the request is synchronous or asynchronous. - * @param user The username to use for authentication purposes. - * @param password The password to use for authentication purposes. + * @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 { _defaultView } = XMLHttpRequest; - // If _defaultView is not defined, then we can't set the URL. - if (!_defaultView) { - throw new Error('need set defaultView'); - } - method = method.toUpperCase(); - if (this.restrictedMethods[method]) { - throw new DOMException( - `HTTP method ${method} is not allowed in XHR`, - DOMExceptionNameEnum.securityError - ); - } + this.abort(); + this._errorFlag = false; + this._abortedFlag = false; - // Get and Parse the URL relative to the given Location object. - const xhrUrl = RelativeURL.getAbsoluteURL(XMLHttpRequest._defaultView.location, url); - // Set username and password if given. - xhrUrl.username = user ? user : xhrUrl.username; - xhrUrl.password = password ? password : xhrUrl.password; + const upperMethod = method.toUpperCase(); - if ( - this.readyState === XMLHttpRequest.HEADERS_RECEIVED || - this.readyState === XMLHttpRequest.LOADING - ) { - // TODO: terminate abort(), terminate send() + // Check for valid request method + if (FORBIDDEN_REQUEST_METHODS.includes(upperMethod)) { + throw new Error('SecurityError: Request method not allowed'); } - this.method = method; - this.url = xhrUrl; - this.auth = `${this.url.username || ''}:${this.url.password || ''}` - this.sync = !async; - // this.headers = {}; - this.loweredHeaders = {}; - // this.mimeOverride = null; - this.setReadyState(XMLHttpRequest.OPENED); - this._request = null; - this._response = null; - this.status = 0; - this.statusText = ''; - this.responseParts = []; - this.responseHeaders = null; - this.loadedBytes = 0; - this.totalBytes = 0; - this.lengthComputable = false; + this._settings = { + method: upperMethod, + url: url.toString(), + async: async, + user: user || null, + password: password || null + }; + this._setState(XMLHttpRequestReadyStateEnum.opened); } /** - * Sets the value of an HTTP request header. + * Sets a header for the request. * - * @param name The name of the header whose value is to be set. - * @param value The value to set as the body of the header. + * @param header Header name + * @param value Header value + * @returns Header added. */ - public setRequestHeader(name: string, value: unknown): void { - const { _defaultView } = XMLHttpRequest; - if (this.readyState !== XMLHttpRequest.OPENED) { - throw new DOMException( - 'XHR readyState must be OPENED', - DOMExceptionNameEnum.invalidStateError - ); + public setRequestHeader(header: string, value: string): boolean { + if (this.readyState != XMLHttpRequestReadyStateEnum.opened) { + throw new Error('INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN'); } + const lowerHeader = header.toLowerCase(); - const loweredName = name.toLowerCase(); - if ( - this.restrictedHeaders[loweredName] || - /^sec-/.test(loweredName) || - /^proxy-/.test(loweredName) - ) { - _defaultView.console.warn(`Refused to set unsafe header "${name}"`); - return; + if (FORBIDDEN_REQUEST_HEADERS.includes(lowerHeader)) { + return false; } - - const headerBody = value.toString(); - if (this.loweredHeaders[loweredName] != null) { - name = this.loweredHeaders[loweredName]; - this.headers[name] = `${this.headers[name]}, ${headerBody}`; - } else { - this.loweredHeaders[loweredName] = name; - this.headers[name] = headerBody; + if (this._sendFlag) { + throw new Error('INVALID_STATE_ERR: send flag is true'); } + this._requestHeaders[lowerHeader] = value; + return true; } /** - * Sends the request. If the request is asynchronous (which is the default), this method returns as soon as the request is sent. + * Gets a header from the server response. * - * @param data The data to send with the request. - */ - public send(data?: string | Buffer | ArrayBuffer | ArrayBufferView): void { - const { invalidStateError, networkError } = DOMExceptionNameEnum; - if (this.readyState !== XMLHttpRequest.OPENED) { - throw new DOMException('XHR readyState must be OPENED', invalidStateError); - } - if (this._request) { - throw new DOMException('send() already called', invalidStateError); - } - switch (this.url.protocol) { - case 'file:': - return this.sendFile(data); - case 'http:': - case 'https:': - return this.sendHttp(data); - default: - throw new DOMException(`Unsupported protocol ${this.url.protocol}`, networkError); - } - } - - /** - * Aborts the request if it has already been sent. + * @param string header Name of header to get. + * @param header + * @returns string Text of the header or null if it doesn't exist. */ - public abort(): void { - if (this._request == null) { - return; - } - // ClientRequest.destroy breaks the test suite for versions 10 and 12, - // Hence the version check - if (MajorNodeVersion > 13) { - this._request.destroy(); - } else { - this._request.abort(); - } - this.setReadyState(XMLHttpRequest.DONE); - this.setError(); - - this.dispatchProgress('abort'); - this.dispatchProgress('loadend'); - } + public getResponseHeader(header: string): string { + const lowerHeader = header.toLowerCase(); - /** - * Returns the string containing the text of the specified header, or null if either the response has not yet been received or the header doesn't exist in the response. - * - * @param name The name of the header. - */ - public getResponseHeader(name: string): string { if ( - this.responseHeaders == null || - name == null || - this.readyState === XMLHttpRequest.OPENED || - this.readyState === XMLHttpRequest.UNSENT + typeof header === 'string' && + this.readyState > XMLHttpRequestReadyStateEnum.opened && + this._response.headers[lowerHeader] && + !this._errorFlag ) { - return null; + return this._response.headers[lowerHeader]; } - const loweredName = name.toLowerCase(); - return this.responseHeaders.hasOwnProperty(loweredName) - ? this.responseHeaders[name.toLowerCase()] - : null; + + return null; } /** - * Returns all the response headers, separated by CRLF, as a string, or null if no response has been received. + * Gets all the response headers. * + * @returns A string with all response headers separated by CR+LF. */ public getAllResponseHeaders(): string { - if ( - this.responseHeaders == null || - this.readyState === XMLHttpRequest.OPENED || - this.readyState === XMLHttpRequest.UNSENT - ) { + if (this.readyState < XMLHttpRequestReadyStateEnum.headersRecieved || this._errorFlag) { return ''; } - return Object.keys(this.responseHeaders) - .map((key) => `${key}: ${this.responseHeaders[key]}`) - .join('\r\n'); - } + let result = ''; - /** - * Overrides the MIME type returned by the server. - * - * @param mimeType The MIME type to use. - */ - public overrideMimeType(mimeType: string): void { - if (this.readyState === XMLHttpRequest.LOADING || this.readyState === XMLHttpRequest.DONE) { - throw new DOMException( - 'overrideMimeType() not allowed in LOADING or DONE', - DOMExceptionNameEnum.invalidStateError - ); + for (const name of Object.keys(this._response.headers)) { + // Cookie headers are excluded + if (name !== 'set-cookie' && name !== 'set-cookie2') { + result += `${name}: ${this._response.headers[name]}\r\n`; + } } - this.mimeOverride = mimeType.toLowerCase(); - } - /** - * Sets the value of the ReadyState. - * - * @param readyState The new value of the ReadyState. - */ - private setReadyState(readyState: number): void { - this.readyState = readyState; - this.dispatchEvent(new ProgressEvent('readystatechange')); - } - - /** - * Send request with file. - * - * @todo Not implemented. - * @param _data File body to send. - */ - private sendFile(_data: unknown): void { - // TODO: sendFile() not implemented. - throw new Error('Protocol file: not implemented'); + return result.substr(0, result.length - 2); } /** - * Send request with http. + * Gets a request header * - * @param data Data to send. + * @param name Name of header to get. + * @returns Returns the request header or empty string if not set. */ - private sendHttp(data?: string | Buffer | ArrayBuffer | ArrayBufferView): void { - const { _defaultView } = XMLHttpRequest; - this.body = data; - - if (this.sync) { - const params = this._serialParams(); - const res = spawnSync(process.execPath, [SyncWorkerFile], { input: params, maxBuffer: Infinity }); - res; - // TODO: sync not implemented. - throw new Error('Synchronous XHR processing not implemented'); - } - if (data && (this.method === 'GET' || this.method === 'HEAD')) { - _defaultView.console.warn(`Discarding entity body for ${this.method} requests`); - data = null; - } else { - data = data || ''; + public getRequestHeader(name: string): string { + const lowerName = name.toLowerCase(); + if (typeof name === 'string' && this._requestHeaders[lowerName]) { + return this._requestHeaders[lowerName]; } - this.upload.setData(data); - this.finalizeHeaders(); - this.sendHxxpRequest(); + return ''; } /** - * SendHxxpRequest sends the actual request. + * Sends the request to the server. * + * @param data Optional data to send as request body. */ - private sendHxxpRequest(): void { - const { _defaultView } = XMLHttpRequest; - if (this.withCredentials) { - // Set cookie if URL is same-origin. - if (_defaultView.location.origin === this.url.origin) { - this.headers.cookie = _defaultView.document.cookie; - } + public send(data: string): void { + if (this.readyState != XMLHttpRequestReadyStateEnum.opened) { + throw new Error('INVALID_STATE_ERR: connection must be opened before send() is called'); } - const [hxxp, agent] = - this.url.protocol === 'http:' ? [http, this.nodejsHttpAgent] : [https, this.nodejsHttpsAgent]; - const requestMethod: (options: RequestOptionsHttp) => ClientRequest = hxxp.request.bind(hxxp); - const request = requestMethod({ - hostname: this.url.hostname, - port: +this.url.port, - path: this.url.pathname, - auth: this.auth, - method: this.method, - headers: this.headers, - agent - }); - this._request = request; - - if (this.timeout) { - request.setTimeout(this.timeout, () => this.onHttpTimeout(request)); + if (this._sendFlag) { + throw new Error('INVALID_STATE_ERR: send has already been called'); } - request.on('response', (response) => this.onHttpResponse(request, response)); - request.on('error', (error) => this.onHttpRequestError(request, error)); - this.upload.startUpload(request); - if (this._request === request) { - this.dispatchProgress('loadstart'); - } - } + let ssl = false; + let local = false; + const url = RelativeURL.getAbsoluteURL( + this._ownerDocument.defaultView.location, + this._settings.url + ); + let host; - /** - * Finalize headers. - * - */ - private finalizeHeaders(): void { - const { _defaultView } = XMLHttpRequest; - this.headers = { - ...this.headers, - Connection: 'keep-alive', - Host: this.url.host, - 'User-Agent': _defaultView.navigator.userAgent, - ...(this.anonymous ? { Referer: 'about:blank' } : { Referer: _defaultView.location.origin }) - }; - this.upload.finalizeHeaders(this.headers, this.loweredHeaders); - } + // Determine the server + switch (url.protocol) { + case 'https:': + ssl = true; + // SSL & non-SSL both need host, no break here. + case 'http:': + host = url.hostname; + break; - /** - * OnHttpResponse handles the response. - * - * @param request The request. - * @param response The response. - */ - private onHttpResponse(request: ClientRequest, response: IncomingMessage): void { - if (this._request !== request) { - return; - } - const { _defaultView } = XMLHttpRequest; + case 'file:': + local = true; + break; + + case undefined: + case '': + host = 'localhost'; + break; - if (this.withCredentials && response.headers['set-cookie']) { - _defaultView.document.cookie = response.headers['set-cookie'].join('; '); + default: + throw new Error('Protocol not supported.'); } - if ([301, 302, 303, 307, 308].indexOf(response.statusCode) >= 0) { - this.url = new _defaultView.URL(response.headers.location); - this.method = 'GET'; - if (this.loweredHeaders['content-type']) { - delete this.headers[this.loweredHeaders['content-type']]; - delete this.loweredHeaders['content-type']; + // Load files off the local filesystem (file://) + if (local) { + if (this._settings.method !== 'GET') { + throw new Error('XMLHttpRequest: Only GET method is supported'); } - if (this.headers['Content-Type'] != null) { - delete this.headers['Content-Type']; + + if (this._settings.async) { + FS.readFile(unescape(url.pathname), 'utf8', (error: Error, data: Buffer) => { + if (error) { + this._handleError(error); + } else { + this.status = 200; + this.responseText = data.toString(); + this._setState(XMLHttpRequestReadyStateEnum.done); + } + }); + } else { + try { + this.responseText = FS.readFileSync(unescape(url.pathname), 'utf8'); + this.status = 200; + this._setState(XMLHttpRequestReadyStateEnum.done); + } catch (error) { + this._handleError(error); + } } - delete this.headers['Content-Length']; - this.upload.reset(); - this.finalizeHeaders(); - this.sendHxxpRequest(); return; } - this._response = response; - this._response.on('data', (data) => this.onHttpResponseData(response, data)); - this._response.on('end', () => this.onHttpResponseEnd(response)); - this._response.on('close', () => this.onHttpResponseClose(response)); - - this.responseUrl = this.url.href.split('#')[0]; - this.status = response.statusCode; - this.statusText = http.STATUS_CODES[this.status]; - this.parseResponseHeaders(response); + // Default to port 80. If accessing localhost on another port be sure + // To use http://localhost:port/path + const port = url.port || (ssl ? 443 : 80); + // Add query string if one is used + const uri = url.pathname + (url.search ? url.search : ''); - const lengthString = this.responseHeaders['content-length'] || ''; - this.totalBytes = +lengthString; - this.lengthComputable = !!lengthString; - - this.setReadyState(XMLHttpRequest.HEADERS_RECEIVED); - } + // Set the Host header or the server may reject the request + this._requestHeaders['host'] = host; + if (!((ssl && port === 443) || port === 80)) { + this._requestHeaders['host'] += ':' + url.port; + } - /** - * OnHttpResponseData handles the response data. - * - * @param response The response. - * @param data The data. - */ - private onHttpResponseData(response: IncomingMessage, data: string | Buffer): void { - if (this._response !== response) { - return; + // Set Basic Auth if necessary + if (this._settings.user) { + if (typeof this._settings.password == 'undefined') { + this._settings.password = ''; + } + const authBuffer = Buffer.from(this._settings.user + ':' + this._settings.password); + this._requestHeaders['authorization'] = 'Basic ' + authBuffer.toString('base64'); } - this.responseParts.push(Buffer.from(data)); - this.loadedBytes += data.length; + // Set content length header + if (this._settings.method === 'GET' || this._settings.method === 'HEAD') { + data = null; + } else if (data) { + this._requestHeaders['content-length'] = Buffer.isBuffer(data) + ? data.length + : Buffer.byteLength(data); - if (this.readyState !== XMLHttpRequest.LOADING) { - this.setReadyState(XMLHttpRequest.LOADING); - } + if (!this._requestHeaders['content-type']) { + this._requestHeaders['content-type'] = 'text/plain;charset=UTF-8'; + } + } else if (this._settings.method === 'POST') { + // For a post with no data set Content-Length: 0. + // This is required by buggy servers that don't meet the specs. + this._requestHeaders['content-length'] = 0; + } + + const options = { + host: host, + port: port, + path: uri, + method: this._settings.method, + headers: Object.assign(this._getDefaultRequestHeaders(), this._requestHeaders), + agent: false, + rejectUnauthorized: true, + key: null, + cert: null + }; - this.dispatchProgress('progress'); - } + if (ssl) { + options.key = SSL_KEY; + options.cert = SSL_CERT; + } + + // Reset error flag + this._errorFlag = false; + // Handle async requests + if (this._settings.async) { + // Use the proper protocol + const sendRequest = ssl ? HTTPS.request : HTTP.request; + + // Request is being sent, set send flag + this._sendFlag = true; + + // As per spec, this is called here for historical reasons. + this.dispatchEvent(new Event('readystatechange')); + + // Handler for the response + const responseHandler = (resp): void => { + // Set response var to the response we got back + // This is so it remains accessable outside this scope + this._response = resp; + + // Check for redirect + // @TODO Prevent looped redirects + if ( + this._response.statusCode === 302 || + this._response.statusCode === 303 || + this._response.statusCode === 307 + ) { + // Change URL to the redirect location + this._settings.url = this._response.headers.location; + const url = new URL(this._settings.url); + // Set host var in case it's used later + host = url.hostname; + // Options for the new request + const newOptions = { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: this._response.statusCode === 303 ? 'GET' : this._settings.method, + headers: Object.assign(this._getDefaultRequestHeaders(), this._requestHeaders), + rejectUnauthorized: true, + key: null, + cert: null + }; + + if (ssl) { + newOptions.key = SSL_KEY; + newOptions.cert = SSL_CERT; + } + + // Issue the new request + this._request = sendRequest(newOptions, responseHandler).on('error', errorHandler); + this._request.end(); + // @TODO Check if an XHR event needs to be fired here + return; + } - /** - * OnHttpResponseEnd handles the response end. - * - * @param response The response. - */ - private onHttpResponseEnd(response: IncomingMessage): void { - if (this._response !== response) { - return; - } + if (this._response && this._response.setEncoding) { + this._response.setEncoding('utf8'); + } - this.parseResponse(); - this._request = null; - this._response = null; - this.setReadyState(XMLHttpRequest.DONE); + this._setState(XMLHttpRequestReadyStateEnum.headersRecieved); + this.status = this._response.statusCode; + + this._response.on('data', (chunk) => { + // Make sure there's some data + if (chunk) { + this.responseText += chunk; + } + // Don't emit state changes if the connection has been aborted. + if (this._sendFlag) { + this._setState(XMLHttpRequestReadyStateEnum.loading); + } + }); - this.dispatchProgress('load'); - this.dispatchProgress('loadend'); - } + this._response.on('end', () => { + if (this._sendFlag) { + // The sendFlag needs to be set before setState is called. Otherwise if we are chaining callbacks + // There can be a timing issue (the callback is called and a new call is made before the flag is reset). + this._sendFlag = false; + // Discard the 'end' event if the connection has been aborted + this._setState(XMLHttpRequestReadyStateEnum.done); + } + }); - /** - * OnHttpResponseClose handles the response close. - * - * @param response The response. - */ - private onHttpResponseClose(response: IncomingMessage): void { - if (this._response !== response) { - return; - } + this._response.on('error', (error) => { + this._handleError(error); + }); + }; - const request = this._request; - this.setError(); - // ClientRequest.destroy breaks the test suite for versions 10 and 12, - // Hence the version check - if (MajorNodeVersion > 13) { - request.destroy(); - } else { - request.abort(); - } - this.setReadyState(XMLHttpRequest.DONE); + // Error handler for the request + const errorHandler = (error): void => { + this._handleError(error); + }; - this.dispatchProgress('error'); - this.dispatchProgress('loadend'); - } + // Create the request + this._request = sendRequest(options, responseHandler).on('error', errorHandler); - /** - * OnHttpTimeout handles the timeout. - * - * @param request The request. - */ - private onHttpTimeout(request: ClientRequest): void { - if (this._request !== request) { - return; - } + // Node 0.4 and later won't accept empty data. Make sure it's needed. + if (data) { + this._request.write(data); + } - this.setError(); - // ClientRequest.destroy breaks the test suite for versions 10 and 12, - // Hence the version check - if (MajorNodeVersion > 13) { - request.destroy(); + this._request.end(); + + this.dispatchEvent(new Event('loadstart')); } else { - request.abort(); - } - this.setReadyState(XMLHttpRequest.DONE); + // Synchronous + // Create a temporary file for communication with the other Node process + const contentFile = '.node-xmlhttprequest-content-' + process.pid; + const syncFile = '.node-xmlhttprequest-sync-' + process.pid; + FS.writeFileSync(syncFile, '', 'utf8'); + + // The async request the other Node process executes + const execString = ` + const HTTP = require('http') + const HTTPS = require('https') + const FS = require('FS'); + const sendRequest = HTTP${ssl ? 'S' : ''}.request; + const options = ${JSON.stringify(options)}; + const responseText = ''; + const request = sendRequest(options, (response) => { + response.setEncoding('utf8'); + response.on('data', (chunk) => { + responseText += chunk; + }); + response.on('end', () => { + FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8'); + FS.unlinkSync('${syncFile}'); + }); + }); + response.on('error', (error) => { + FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8').on('error', (error) => { + FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8'); + FS.unlinkSync('${syncFile}')); + }); + }); + request.write(\`${JSON.stringify(data).slice(1, -1)}\`); + request.end(); + `.trim(); + + // Start the other Node Process, executing this string + const syncProc = ChildProcess.spawn(process.argv[0], ['-e', execString]); + + while (FS.existsSync(syncFile)) { + // Wait while the sync file is empty + } - this.dispatchProgress('timeout'); - this.dispatchProgress('loadend'); + this.responseText = FS.readFileSync(contentFile, 'utf8'); + + // Kill the child process once the file has data + syncProc.stdin.end(); + + // Remove the temporary file + FS.unlinkSync(contentFile); + + if (this.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR:/)) { + // If the file returned an error, handle it + const errorObj = this.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, ''); + this._handleError(errorObj, 503); + } else { + // If the file returned okay, parse its data and move to the DONE state + this.status = Number( + this.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:([0-9]*),.*/, '$1') + ); + this.responseText = this.responseText.replace( + /^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/, + '$1' + ); + this._setState(XMLHttpRequestReadyStateEnum.done); + } + } } /** - * OnHttpRequestError handles the request error. - * - * @param request The request. - * @param error The error. + * Aborts a request. */ - private onHttpRequestError(request: ClientRequest, error: Error): void { - this._error = error; - if (this._request !== request) { - return; - } - - this.setError(); - // ClientRequest.destroy breaks the test suite for versions 10 and 12, - // Hence the version check - if (MajorNodeVersion > 13) { - request.destroy(); - } else { - request.abort(); + public abort(): void { + if (this._request) { + this._request.abort(); + this._request = null; } - this.setReadyState(XMLHttpRequest.DONE); - this.dispatchProgress('error'); - this.dispatchProgress('loadend'); - } + this._requestHeaders = {}; + this.responseText = ''; + this.responseXML = ''; - /** - * Dispatches the progress event. - * - * @param eventType The event type. - */ - private dispatchProgress(eventType: string): void { - const event = new ProgressEvent(eventType, { - lengthComputable: this.lengthComputable, - loaded: this.loadedBytes, - total: this.totalBytes - }); - this.dispatchEvent(event); + this._errorFlag = this._abortedFlag = true; + if ( + this.readyState !== XMLHttpRequestReadyStateEnum.unsent && + (this.readyState !== XMLHttpRequestReadyStateEnum.opened || this._sendFlag) && + this.readyState !== XMLHttpRequestReadyStateEnum.done + ) { + this._sendFlag = false; + this._setState(XMLHttpRequestReadyStateEnum.done); + } + this.readyState = XMLHttpRequestReadyStateEnum.unsent; } /** - * Sets the error. + * Called when an error is encountered to deal with it. * + * @param error Error object. + * @param status HTTP status code to use rather than the default (0) for XHR errors. */ - private setError(): void { - this._request = null; - this._response = null; - this.responseHeaders = null; - this.responseParts = null; + private _handleError(error: Error | string, status = 0): void { + this.status = status; + this.statusText = error.toString(); + this.responseText = error instanceof Error ? error.stack : ''; + this._errorFlag = true; + this._setState(XMLHttpRequestReadyStateEnum.done); } /** - * Parses the response headers. + * Changes readyState and calls onreadystatechange. * - * @param response The response. + * @param int state New state + * @param state */ - private parseResponseHeaders(response: IncomingMessage): void { - this.responseHeaders = {}; - for (const name in response.headers) { - const loweredName = name.toLowerCase(); - if (this.privateHeaders[loweredName]) { - continue; - } - this.responseHeaders[loweredName] = response.headers[name]; + private _setState(state: XMLHttpRequestReadyStateEnum): void { + if ( + this.readyState === state || + (this.readyState === XMLHttpRequestReadyStateEnum.unsent && this._abortedFlag) + ) { + return; } - if (this.mimeOverride != null) { - this.responseHeaders['content-type'] = this.mimeOverride; + + this.readyState = state; + + if ( + this._settings.async || + this.readyState < XMLHttpRequestReadyStateEnum.opened || + this.readyState === XMLHttpRequestReadyStateEnum.done + ) { + this.dispatchEvent(new Event('readystatechange')); } - } - /** - * Parses the response. - * - */ - private parseResponse(): void { - const buffer = Buffer.concat(this.responseParts); - this.responseParts = null; + if (this.readyState === XMLHttpRequestReadyStateEnum.done) { + let fire: Event; - switch (this.responseType) { - case 'json': - this.responseText = null; - try { - this.response = JSON.parse(buffer.toString('utf-8')); - } catch { - this.response = null; - } - break; - case 'buffer': - this.responseText = null; - this.response = buffer; - break; - case 'arraybuffer': - this.responseText = null; - this.response = copyToArrayBuffer(buffer); - break; - case 'blob': - this.responseText = null; - this.response = new Blob([new Uint8Array(buffer)], { - type: this.mimeOverride || this.responseHeaders['content-type'] || '' - }); - break; - case 'document': - // TODO: MimeType parse not yet supported. - break; - case 'text': - default: - try { - this.responseText = buffer.toString(this.parseResponseEncoding()); - } catch { - this.responseText = buffer.toString('binary'); - } - this.response = this.responseText; - break; + if (this._abortedFlag) { + fire = new Event('abort'); + } else if (this._errorFlag) { + fire = new Event('error'); + } else { + fire = new Event('load'); + } + + this.dispatchEvent(fire); + this.dispatchEvent(new Event('loadend')); } - return; } /** - * Parses the response encoding. + * Default request headers. * + * @returns Default request headers. */ - private parseResponseEncoding(): string { - const charset = /;\s*charset=(.*)$/.exec(this.responseHeaders['content-type'] || ''); - return Array.isArray(charset) ? charset[1] : 'utf-8'; - } - - public _syncGetError(): Error { - return this._error; - } - public _syncSetErrorString(error: string): void { - this._error = error; - } - - private _serialParams(): string { - const { _defaultView } = XMLHttpRequest; - const serials = { - sync: this.sync, - withCredentials: this.withCredentials, - mimeType: this.mimeOverride, - username: this.url.username, - password: this.url.password, - auth: this.auth, - method: this.method, - responseType: this.responseType, - headers: this.headers, - uri: this.url.href, - timeout: this.timeout, - body: this.body, - - cookie: _defaultView.document.cookie, - origin: _defaultView.location.href + private _getDefaultRequestHeaders(): { [key: string]: string } { + return { + accept: '*/*', + refered: this._ownerDocument.defaultView.location.origin, + 'user-agent': this._ownerDocument.defaultView.navigator.userAgent, + cookie: this._ownerDocument.defaultView.document.cookie }; - return JSON.stringify(serials); } } diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestEventTarget.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestEventTarget.ts index c04e4f4b3..90cb15e1e 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequestEventTarget.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestEventTarget.ts @@ -6,12 +6,12 @@ export type ProgressEventListener = (event: ProgressEvent) => void; /** * References: https://xhr.spec.whatwg.org/#xmlhttprequesteventtarget. */ -export class XMLHttpRequestEventTarget extends EventTarget { - public onloadstart: ProgressEventListener | null; - public onprogress: ProgressEventListener | null; - public onabort: ProgressEventListener | null; - public onerror: ProgressEventListener | null; - public onload: ProgressEventListener | null; - public ontimeout: ProgressEventListener | null; - public onloadend: ProgressEventListener | null; +export default class XMLHttpRequestEventTarget extends EventTarget { + public onloadstart: ProgressEventListener | null = null; + public onprogress: ProgressEventListener | null = null; + public onabort: (event: ProgressEvent) => void | null = null; + public onerror: ProgressEventListener | null = null; + public onload: ProgressEventListener | null = null; + public ontimeout: ProgressEventListener | null = null; + public onloadend: ProgressEventListener | null = null; } diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestReadyStateEnum.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestReadyStateEnum.ts new file mode 100644 index 000000000..3996ceb6b --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestReadyStateEnum.ts @@ -0,0 +1,9 @@ +enum XMLHttpRequestReadyStateEnum { + unsent = 0, + opened = 1, + headersRecieved = 2, + loading = 3, + done = 4 +} + +export default XMLHttpRequestReadyStateEnum; diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestSyncWorker.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestSyncWorker.ts deleted file mode 100644 index ed97d8eaa..000000000 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequestSyncWorker.ts +++ /dev/null @@ -1,73 +0,0 @@ -import Window from '../window/Window'; -import * as util from 'util'; - -const window = new Window(); -const xhr = new window.XMLHttpRequest(); - -const chunks = []; - -process.stdin.on('data', (chunk) => { - chunks.push(chunk); -}); - -process.stdin.on('end', () => { - const buffer = Buffer.concat(chunks); - - const serials = JSON.parse(buffer.toString()); - if (serials.body && serials.body.type === 'Buffer' && serials.body.data) { - serials.body = Buffer.from(serials.body.data); - } - if (serials.origin) { - window.location.href = serials.origin; - } - - if (serials.cookie) { - window.document.cookie = serials.cookie; - } - - xhr.overrideMimeType(serials.mimeType); - xhr.open(serials.method, serials.uri, true, serials.user, serials.password); - if (serials.headers) { - Object.keys(serials.headers).forEach((key) => { - xhr.setRequestHeader(key, serials.headers[key]); - }); - } - - - xhr.timeout = serials.timeout; - - try { - xhr.addEventListener('loadend', () => { - if (xhr._syncGetError()) { - const err = xhr._syncGetError(); - xhr._syncSetErrorString(err.stack || util.inspect(err)); - } - - process.stdout.write( - JSON.stringify({ - responseURL: xhr.responseUrl, - responseText: xhr.responseText, - status: xhr.status, - statusText: xhr.statusText, - }), - () => { - process.exit(0); - } - ); - }); - - xhr.send(serials.body); - } catch (error) { - // Properties.error += error.stack || util.inspect(error); - process.stdout.write( - JSON.stringify({ - responseURL: xhr.responseUrl, - status: xhr.status, - statusText: xhr.statusText - }), - () => { - process.exit(0); - } - ); - } -}); diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts index da9c8c710..d0d4c5dcc 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestUpload.ts @@ -1,86 +1,6 @@ -import { XMLHttpRequestEventTarget } from './XMLHttpRequestEventTarget'; -import { ClientRequest } from 'http'; +import XMLHttpRequestEventTarget from './XMLHttpRequestEventTarget'; /** * References: https://xhr.spec.whatwg.org/#xmlhttprequestupload. */ -export default class XMLHttpRequestUpload extends XMLHttpRequestEventTarget { - private _contentType: string | null = null; - private _body = null; - - /** - * Create a new XMLHttpRequestUpload object. - */ - constructor() { - super(); - this.reset(); - } - - /** - * Reset the upload. - */ - public reset(): void { - this._contentType = null; - this._body = null; - } - - /** - * Set data to be sent. - * - * @param data The data to be sent. - */ - public setData(data?: string | Buffer | ArrayBuffer | ArrayBufferView): void { - if (data == null) { - return; - } - - if (typeof data === 'string') { - if (data.length !== 0) { - this._contentType = 'text/plain;charset=UTF-8'; - } - this._body = Buffer.from(data, 'utf-8'); - } else if (Buffer.isBuffer(data)) { - this._body = data; - } else if (data instanceof ArrayBuffer) { - const body = Buffer.alloc(data.byteLength); - const view = new Uint8Array(data); - body.set(view); - this._body = body; - } else if (data.buffer && data.buffer instanceof ArrayBuffer) { - const body = Buffer.alloc(data.byteLength); - const offset = data.byteOffset; - const view = new Uint8Array(data.buffer); - body.set(view, offset); - this._body = body; - } else { - throw new Error(`Unsupported send() data ${data}`); - } - } - - /** - * Finalize headers. - * - * @param headers The headers to be finalized. - * @param loweredHeaders The lowered headers to be finalized. - */ - public finalizeHeaders(headers: object, loweredHeaders: object): void { - if (this._contentType && !loweredHeaders['content-type']) { - headers['Content-Type'] = this._contentType; - } - if (this._body) { - headers['Content-Length'] = this._body.length.toString(); - } - } - - /** - * Start upload. - * - * @param request The request. - */ - public startUpload(request: ClientRequest): void { - if (this._body) { - request.write(this._body); - } - request.end(); - } -} +export default class XMLHttpRequestUpload extends XMLHttpRequestEventTarget {} From fc8a84567ccab3b820d094278d34e56828ae0f21 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 16 Oct 2022 00:08:56 +0800 Subject: [PATCH 14/40] #526@minor: Fixes problem. --- .../src/xml-http-request/XMLHttpRequest.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index ad88299e8..4e26f4810 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -263,7 +263,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * * @param data Optional data to send as request body. */ - public send(data: string): void { + public send(data?: string): void { if (this.readyState != XMLHttpRequestReadyStateEnum.opened) { throw new Error('INVALID_STATE_ERR: connection must be opened before send() is called'); } @@ -283,8 +283,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Determine the server switch (url.protocol) { case 'https:': + host = url.hostname; ssl = true; - // SSL & non-SSL both need host, no break here. + break; + case 'http:': host = url.hostname; break; @@ -302,6 +304,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { throw new Error('Protocol not supported.'); } + // TODO: Security Issue. // Load files off the local filesystem (file://) if (local) { if (this._settings.method !== 'GET') { @@ -309,7 +312,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } if (this._settings.async) { - FS.readFile(unescape(url.pathname), 'utf8', (error: Error, data: Buffer) => { + FS.readFile(decodeURI(url.pathname.slice(1)), 'utf8', (error: Error, data: Buffer) => { if (error) { this._handleError(error); } else { @@ -320,7 +323,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { }); } else { try { - this.responseText = FS.readFileSync(unescape(url.pathname), 'utf8'); + this.responseText = FS.readFileSync(decodeURI(url.pathname.slice(1)), 'utf8'); this.status = 200; this._setState(XMLHttpRequestReadyStateEnum.done); } catch (error) { @@ -501,11 +504,11 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { const execString = ` const HTTP = require('http') const HTTPS = require('https') - const FS = require('FS'); + const FS = require('fs'); const sendRequest = HTTP${ssl ? 'S' : ''}.request; const options = ${JSON.stringify(options)}; - const responseText = ''; const request = sendRequest(options, (response) => { + let responseText = ''; response.setEncoding('utf8'); response.on('data', (chunk) => { responseText += chunk; @@ -514,12 +517,12 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8'); FS.unlinkSync('${syncFile}'); }); - }); - response.on('error', (error) => { - FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8').on('error', (error) => { - FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8'); - FS.unlinkSync('${syncFile}')); - }); + response.on('error', (error) => { + FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8').on('error', (error) => { + FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8'); + FS.unlinkSync('${syncFile}'); + }); + }); }); request.write(\`${JSON.stringify(data).slice(1, -1)}\`); request.end(); @@ -528,9 +531,8 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Start the other Node Process, executing this string const syncProc = ChildProcess.spawn(process.argv[0], ['-e', execString]); - while (FS.existsSync(syncFile)) { - // Wait while the sync file is empty - } + // TODO: Block when a request exception cannot get syncFile. + while (FS.existsSync(syncFile)) {} // Wait while the sync file is empty this.responseText = FS.readFileSync(contentFile, 'utf8'); @@ -645,7 +647,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { private _getDefaultRequestHeaders(): { [key: string]: string } { return { accept: '*/*', - refered: this._ownerDocument.defaultView.location.origin, + referer: this._ownerDocument.defaultView.location.origin, 'user-agent': this._ownerDocument.defaultView.navigator.userAgent, cookie: this._ownerDocument.defaultView.document.cookie }; From 40cd3c19527794ed6b0bbaac5d001173ac719a48 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 16 Oct 2022 10:53:11 +0800 Subject: [PATCH 15/40] #526@minor: Continue Fixes Problem. --- .../src/xml-http-request/XMLHttpRequest.ts | 44 ++++++++----------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 4e26f4810..20c0a0f59 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -168,7 +168,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._settings = { method: upperMethod, - url: url.toString(), + url: url, async: async, user: user || null, password: password || null @@ -203,8 +203,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { /** * Gets a header from the server response. * - * @param string header Name of header to get. - * @param header + * @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 { @@ -240,7 +239,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } } - return result.substr(0, result.length - 2); + return result.slice(0, -2); } /** @@ -428,15 +427,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { method: this._response.statusCode === 303 ? 'GET' : this._settings.method, headers: Object.assign(this._getDefaultRequestHeaders(), this._requestHeaders), rejectUnauthorized: true, - key: null, - cert: null + key: ssl ? SSL_KEY : null, + cert: ssl ? SSL_CERT : null }; - if (ssl) { - newOptions.key = SSL_KEY; - newOptions.cert = SSL_CERT; - } - // Issue the new request this._request = sendRequest(newOptions, responseHandler).on('error', errorHandler); this._request.end(); @@ -464,7 +458,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._response.on('end', () => { if (this._sendFlag) { - // The sendFlag needs to be set before setState is called. Otherwise if we are chaining callbacks + // The sendFlag needs to be set before setState is called. Otherwise, if we are chaining callbacks // There can be a timing issue (the callback is called and a new call is made before the flag is reset). this._sendFlag = false; // Discard the 'end' event if the connection has been aborted @@ -502,8 +496,8 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // The async request the other Node process executes const execString = ` - const HTTP = require('http') - const HTTPS = require('https') + const HTTP = require('http'); + const HTTPS = require('https'); const FS = require('fs'); const sendRequest = HTTP${ssl ? 'S' : ''}.request; const options = ${JSON.stringify(options)}; @@ -518,27 +512,26 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { FS.unlinkSync('${syncFile}'); }); response.on('error', (error) => { - FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8').on('error', (error) => { - FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8'); - FS.unlinkSync('${syncFile}'); - }); - }); + FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8').on('error', (error) => { + FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8'); + FS.unlinkSync('${syncFile}'); + }); + }); }); request.write(\`${JSON.stringify(data).slice(1, -1)}\`); request.end(); `.trim(); // Start the other Node Process, executing this string - const syncProc = ChildProcess.spawn(process.argv[0], ['-e', execString]); + ChildProcess.execFileSync(process.argv[0], ['-e', execString]); - // TODO: Block when a request exception cannot get syncFile. - while (FS.existsSync(syncFile)) {} // Wait while the sync file is empty + // if syncFile still exists, the request failed, if contentFile doesn't exist, the request failed. + if (FS.existsSync(syncFile) || !FS.existsSync(contentFile)) { + throw new Error('Synchronous request failed'); + } this.responseText = FS.readFileSync(contentFile, 'utf8'); - // Kill the child process once the file has data - syncProc.stdin.end(); - // Remove the temporary file FS.unlinkSync(contentFile); @@ -602,7 +595,6 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { /** * Changes readyState and calls onreadystatechange. * - * @param int state New state * @param state */ private _setState(state: XMLHttpRequestReadyStateEnum): void { From 9cce57942f27c1fa712008e97079bd0b34c1424c Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 16 Oct 2022 11:53:48 +0800 Subject: [PATCH 16/40] #526@minor: Continue Fixes Problem. --- packages/happy-dom/src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/happy-dom/src/index.ts b/packages/happy-dom/src/index.ts index e11bc7ade..ddd49e8e9 100644 --- a/packages/happy-dom/src/index.ts +++ b/packages/happy-dom/src/index.ts @@ -4,7 +4,7 @@ import Window from './window/Window'; import DataTransfer from './event/DataTransfer'; import DataTransferItem from './event/DataTransferItem'; import DataTransferItemList from './event/DataTransferItemList'; -import URL from './location/URL'; +import { URL, URLSearchParams } from 'url'; import Location from './location/Location'; import MutationObserver from './mutation-observer/MutationObserver'; import ResizeObserver from './resize-observer/ResizeObserver'; @@ -115,7 +115,6 @@ import CSSMediaRule from './css/rules/CSSMediaRule'; import CSSStyleRule from './css/rules/CSSStyleRule'; import Storage from './storage/Storage'; import DOMRect from './nodes/element/DOMRect'; -import { URLSearchParams } from 'url'; import Selection from './selection/Selection'; import Range from './range/Range'; import HTMLDialogElement from './nodes/html-dialog-element/HTMLDialogElement'; From b72cdd9d0571873214df68673f4acfd63ca751e0 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 16 Oct 2022 11:57:01 +0800 Subject: [PATCH 17/40] #526@minor: Continue Fixes Problem. --- packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 20c0a0f59..89e38ccc7 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -525,7 +525,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Start the other Node Process, executing this string ChildProcess.execFileSync(process.argv[0], ['-e', execString]); - // if syncFile still exists, the request failed, if contentFile doesn't exist, the request failed. + // If syncFile still exists, the request failed, if contentFile doesn't exist, the request failed. if (FS.existsSync(syncFile) || !FS.existsSync(contentFile)) { throw new Error('Synchronous request failed'); } From 608b717dec3d66673753fb06e4751249fa3fa8e6 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 16 Oct 2022 13:29:47 +0800 Subject: [PATCH 18/40] #526@minor: Continue Fixes Problem. --- packages/happy-dom/test/window/Window.test.ts | 17 +++++++++++++---- .../xml-http-request/XMLHttpRequest.test.ts | 18 ++++++++---------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/happy-dom/test/window/Window.test.ts b/packages/happy-dom/test/window/Window.test.ts index 8c8a92837..85fb21419 100644 --- a/packages/happy-dom/test/window/Window.test.ts +++ b/packages/happy-dom/test/window/Window.test.ts @@ -451,12 +451,15 @@ describe('Window', () => { it(`Handles successful "${method}" request.`, async () => { const expectedUrl = 'https://localhost:8080/path/'; const expectedOptions = {}; - const response = await window.fetch(expectedUrl, expectedOptions); const result = await response[method](); expect(MOCKED_NODE_FETCH.url).toBe(expectedUrl); - expect(MOCKED_NODE_FETCH.init).toBe(expectedOptions); + + expect(MOCKED_NODE_FETCH.init.headers['user-agent']).toBe(window.navigator.userAgent); + expect(MOCKED_NODE_FETCH.init.headers['cookie']).toBe(window.document.cookie); + expect(MOCKED_NODE_FETCH.init.headers['referer']).toBe(window.location.origin); + expect(result).toEqual(MOCKED_NODE_FETCH.response[method]); }); } @@ -471,13 +474,17 @@ describe('Window', () => { const textResponse = await response.text(); expect(MOCKED_NODE_FETCH.url).toBe('https://localhost:8080' + expectedPath); - expect(MOCKED_NODE_FETCH.init).toBe(expectedOptions); + + expect(MOCKED_NODE_FETCH.init.headers['user-agent']).toBe(window.navigator.userAgent); + expect(MOCKED_NODE_FETCH.init.headers['cookie']).toBe(window.document.cookie); + expect(MOCKED_NODE_FETCH.init.headers['referer']).toBe(window.location.origin); + expect(textResponse).toEqual(MOCKED_NODE_FETCH.response.text); }); it('Handles error JSON request.', async () => { MOCKED_NODE_FETCH.error = new Error('error'); - + window.location.href = 'https://localhost:8080'; try { await window.fetch('/url/', {}); } catch (error) { @@ -488,6 +495,7 @@ describe('Window', () => { describe('happyDOM.whenAsyncComplete()', () => { it('Resolves the Promise returned by whenAsyncComplete() when all async tasks has been completed.', async () => { + window.location.href = 'https://localhost:8080'; let isFirstWhenAsyncCompleteCalled = false; window.happyDOM.whenAsyncComplete().then(() => { isFirstWhenAsyncCompleteCalled = true; @@ -527,6 +535,7 @@ describe('Window', () => { describe('happyDOM.cancelAsync()', () => { it('Cancels all ongoing asynchrounous tasks.', (done) => { + window.location.href = 'https://localhost:8080'; let isFirstWhenAsyncCompleteCalled = false; window.happyDOM.whenAsyncComplete().then(() => { isFirstWhenAsyncCompleteCalled = true; diff --git a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts index a8503612e..253222398 100644 --- a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts +++ b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts @@ -1,18 +1,16 @@ -import { XMLHttpRequest } from '../../lib/xml-http-request/xml-http-request'; +import XMLHttpRequest from '../../src/xml-http-request/XMLHttpRequest'; +import Window from '../../src/window/Window'; describe('XMLHttpRequest', () => { + let window: Window; + // @ts-ignore let xhr: XMLHttpRequest; beforeEach(() => { - xhr = new XMLHttpRequest(); + window = new Window(); + xhr = new window.XMLHttpRequest(); }); - it('XMLHttpRequest()', function () { - xhr.open('GET', 'http://localhost:8080/path/to/resource/', false); - xhr.addEventListener('load', () => { - expect(this.status).toBe(200); - expect(this.responseText).toBe('test'); - expect(this.response).toBe('test'); - }); - xhr.send(); + it('XMLHttpRequest()', () => { + // TODO: Implement }); }); From 855ed45e2b477a91878e507699055b7af295ce05 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 16 Oct 2022 13:34:06 +0800 Subject: [PATCH 19/40] #463@minor: Continue Fixes Problem. --- packages/happy-dom/src/window/Window.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index 8282abee3..0a87bc082 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -429,6 +429,10 @@ export default class Window extends EventTarget implements IWindow { class XMLHttpRequest extends XMLHttpRequestImplementation { public static _ownerDocument: IDocument = document; } + + class Range extends RangeImplementation { + public static _ownerDocument: IDocument = document; + } /* eslint-enable jsdoc/require-jsdoc */ this.Response = Response; @@ -437,6 +441,7 @@ export default class Window extends EventTarget implements IWindow { this.FileReader = FileReader; this.DOMParser = DOMParser; this.XMLHttpRequest = XMLHttpRequest; + this.Range = Range; this._setupVMContext(); From aaf867369ebaa30b255bcd33acd0d5ae1060dced Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 16 Oct 2022 13:37:29 +0800 Subject: [PATCH 20/40] #463@minor: Continue Fixes Problem. --- packages/happy-dom/test/location/URL.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/happy-dom/test/location/URL.test.ts b/packages/happy-dom/test/location/URL.test.ts index 3e94013cc..ca4cf1eda 100644 --- a/packages/happy-dom/test/location/URL.test.ts +++ b/packages/happy-dom/test/location/URL.test.ts @@ -1,10 +1,14 @@ -import URL from '../../src/location/URL'; +import Window from '../../src/window/Window'; describe('URL', () => { + let window: Window; + beforeEach(() => { + window = new Window(); + }); describe('constructor()', () => { it('Parses "https://google.com/some-path/?key=value&key2=value2#hash".', () => { const href = 'https://google.com/some-path/?key=value&key2=value2#hash'; - const url = new URL(href); + const url = new window.URL(href); expect(url.href).toBe(href); expect(url.protocol).toBe('https:'); expect(url.hostname).toBe('google.com'); @@ -20,7 +24,7 @@ describe('URL', () => { it('Parses "https://user:password@google.com/some-path/".', () => { const href = 'https://user:password@google.com/some-path/'; - const url = new URL(href); + const url = new window.URL(href); expect(url.href).toBe(href); expect(url.protocol).toBe('https:'); expect(url.hostname).toBe('google.com'); @@ -36,7 +40,7 @@ describe('URL', () => { it('Parses "https://google.com:8080/some-path/".', () => { const href = 'https://google.com:8080/some-path/'; - const url = new URL(href); + const url = new window.URL(href); expect(url.href).toBe(href); expect(url.protocol).toBe('https:'); expect(url.hostname).toBe('google.com'); @@ -52,7 +56,7 @@ describe('URL', () => { it('Parses "https://google.com".', () => { const formatHref = 'https://google.com/'; const href = 'https://google.com'; - const url = new URL(href); + const url = new window.URL(href); expect(url.href).toBe(formatHref); expect(url.protocol).toBe('https:'); expect(url.hostname).toBe('google.com'); From 2ee990e15a6c62f0af4ce959320b3fc3ec568731 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 16 Oct 2022 18:57:57 +0800 Subject: [PATCH 21/40] #463@minor: Found Bugs in CookieJar, add more support for syncRequest. --- .../src/xml-http-request/XMLHttpRequest.ts | 598 +++++++++++------- 1 file changed, 354 insertions(+), 244 deletions(-) diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 89e38ccc7..58ad37a3d 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -1,5 +1,4 @@ import FS from 'fs'; -import { URL } from 'url'; import ChildProcess from 'child_process'; import HTTP from 'http'; import HTTPS from 'https'; @@ -9,57 +8,14 @@ import Event from '../event/Event'; import IDocument from '../nodes/document/IDocument'; import RelativeURL from '../location/RelativeURL'; import XMLHttpRequestUpload from './XMLHttpRequestUpload'; +import DOMException from '../exception/DOMException'; +import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum'; +import { UrlObject } from 'url'; +import { IXMLHttpRequestOptions } from './IXMLHttpRequest'; -const SSL_CERT = `-----BEGIN CERTIFICATE----- -MIIDYzCCAkugAwIBAgIUJRKB/H66hpet1VfUlm0CiXqePA4wDQYJKoZIhvcNAQEL -BQAwQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVNYWxt -bzESMBAGA1UECgwJSGFwcHkgRE9NMB4XDTIyMTAxMTIyMDM0OVoXDTMyMTAwODIy -MDM0OVowQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVN -YWxtbzESMBAGA1UECgwJSGFwcHkgRE9NMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEAqerQSQEg/SxVxRiwlItithr5e5EMZo1nsqt/xOxagbmpW3IEmt0j -bpbH7iEF4DDEo7KAOwUCOwVWeFxRoag8lG2ax48wrgjlCna45XDn0Xeg1ARajL04 -gs46HZ0VrzIloVGfln0zgt/Vum5BNqs9Oc5fQoBmoP3cAn3dn4ZVcP0AKthtcyPl -q2DuNRN0PV0D2RtMSiAy9l1Ko6N5x+sAeClDyOL+sTDLngZBVeZyOKt9Id15S8Zt -XtA6VMgHnnF3jChn7pag77rsd/y5iANAVNZYqRl+Eg7xaDcsvbgH46UBOrBcB39Q -tTh5Mtjoxep5e3ZDFG+kQ1HUE+iz5O5n0wIDAQABo1MwUTAdBgNVHQ4EFgQU69s9 -YSobG/m2SN4L/7zTaF7iDbwwHwYDVR0jBBgwFoAU69s9YSobG/m2SN4L/7zTaF7i -DbwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAi/WUXx2oal8L -YnPlIuKfh49n/K18wXSYG//oFYwxfVxqpYH8hUiXVm/GUcXCxS++hUkaKLqXmH9q -MKJiCrZr3vS+2nsBKopkICu/TLdROl0sAI9lByfnEbfSAzjxe1IWJdK8NdY0y5m5 -9pEr/URVIAp/CxrneyASb4q0Jg5To3FR7vYc+2X6wZn0MundKMg6Dp9/A37jiF3l -Tt/EJp299YZcsUzh+LnRuggRjnoOVu1aLcLFlaUiwZfy9m8mLG6B/mdW/qNzNMh9 -Oqvg1zfGdpz/4D/2UUUBn6pq1vbsoAaF3OesoA3mfDcegDf/H9woJlpT0Wql+e68 -Y3FblSokcA== ------END CERTIFICATE-----`; - -const SSL_KEY = `-----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCp6tBJASD9LFXF -GLCUi2K2Gvl7kQxmjWeyq3/E7FqBualbcgSa3SNulsfuIQXgMMSjsoA7BQI7BVZ4 -XFGhqDyUbZrHjzCuCOUKdrjlcOfRd6DUBFqMvTiCzjodnRWvMiWhUZ+WfTOC39W6 -bkE2qz05zl9CgGag/dwCfd2fhlVw/QAq2G1zI+WrYO41E3Q9XQPZG0xKIDL2XUqj -o3nH6wB4KUPI4v6xMMueBkFV5nI4q30h3XlLxm1e0DpUyAeecXeMKGfulqDvuux3 -/LmIA0BU1lipGX4SDvFoNyy9uAfjpQE6sFwHf1C1OHky2OjF6nl7dkMUb6RDUdQT -6LPk7mfTAgMBAAECggEAKkwTkTjAt4UjzK56tl+EMQTB+ep/hb/JgoaChci4Nva6 -m9LkJpDJ0yuhlTuPNOGu8XjrxsVWas7HWarRf0Zb3i7yip6wZYI9Ub+AA015x4DZ -/i0fRU2NFbK0cM67qSL4jxG8gj+kZP3HPGNZxHwX/53JxMolwgmvjMc8NgvAlSFd -NnV9h4xtbhUh1NGS5zmP3iU2rwnE8JrIEzwy6axLom7nekAgkdcbAr0UoBs8gcgH -aYNhU4Gz3tGcZZ0IXAfT/bJIH1Ko8AGv4pssWc3BXcmmNdm/+kzvHIxEIV7Qegmo -XG1ZyZCyD/0b4/3e8ySDBEDqwR+HeyTW2isWG2agAQKBgQDp44aTwr3dkIXY30xv -FPfUOipg/B49dWnffYJ9MWc1FT9ijNPAngWSk0EIiEQIazICcUBI4Yji6/KeyqLJ -GdLpDi1CkKqtyh73mjELinYp3EUQgEa77aQogGa2+nMOVfu+O5CtloUrv/A18jX3 -+VEyaEASK0fWmnSI0OdlxQHIAQKBgQC5+xOls2F3MlKASvWRLlnW1wHqlDTtVoYg -5Nh8syZH4Ci2UH8tON3A5/7SWNM0t1cgV6Cw4zW8Z2spgIT/W0iYYrQ4hHL1xdCu -+CxL1km4Gy8Uwpsd+KdFahFqF/XTmLzW0HXLxWSK0fTwmdV0SFrKF3MXfTCU2AeZ -jJoMFb6P0wKBgQC3Odw6s0vkYAzLGhuZxfZkVvDOK5RRF0NKpttr0iEFL9EJFkPo -2KKK8jr3QTDy229BBJGUxsJi6u6VwS8HlehpVQbV59kd7oKV/EBBx0XMg1fDlopT -PNbmN7i/zbIG4AsoOyebJZjL7kBzMn1e9vzKHWtcEHXlw/hZGja8vjooAQKBgAeg -xK2HLfg1mCyq5meN/yFQsENu0LzrT5UJzddPgcJw7zqLEqxIKNBAs7Ls8by3yFsL -PQwERa/0jfCl1M6kb9XQNpQa2pw6ANUsWKTDpUJn2wZ+9N3F1RaDwzMWyH5lRVmK -M0qoTfdjpSg5Jwgd75taWt4bxGJWeflSSv8z5R0BAoGAWL8c527AbeBvx2tOYKkD -2TFranvANNcoMrbeviZSkkGvMNDP3p8b6juJwXOIeWNr8q4vFgCzLmq6d1/9gYm2 -3XJwwyD0LKlqzkBrrKU47qrnmMosUrIRlrAzd3HbShOptxc6Iz2apSaUDKGKXkaw -gl5OpEjeliU7Mus0BVS858g= ------END PRIVATE KEY-----`; +const SSL_CERT = ``; + +const SSL_KEY = ``; // These headers are not user setable. // The following are allowed but banned in the spec: @@ -111,6 +67,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { public readyState: XMLHttpRequestReadyStateEnum = XMLHttpRequestReadyStateEnum.unsent; public responseText = ''; public responseXML = ''; + public responseURL = ''; + public response = null; + public _responseType = ''; public status: number = null; public statusText: string = null; public upload: XMLHttpRequestUpload = new XMLHttpRequestUpload(); @@ -145,6 +104,37 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._ownerDocument = XMLHttpRequest._ownerDocument; } + /** + * Set response type. + * + * @param type Response type. + * + */ + public set responseType(type: string) { + if (this.readyState !== XMLHttpRequestReadyStateEnum.opened) { + throw new DOMException( + `Failed to set the 'responseType' property on 'XMLHttpRequest': The object's state must be OPENED.`, + DOMExceptionNameEnum.invalidStateError + ); + } + if (this._settings.async === false) { + throw new 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(): string { + return this._responseType; + } + /** * Opens the connection. * @@ -163,7 +153,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Check for valid request method if (FORBIDDEN_REQUEST_METHODS.includes(upperMethod)) { - throw new Error('SecurityError: Request method not allowed'); + throw new DOMException('Request method not allowed', DOMExceptionNameEnum.securityError); } this._settings = { @@ -186,7 +176,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { */ public setRequestHeader(header: string, value: string): boolean { if (this.readyState != XMLHttpRequestReadyStateEnum.opened) { - throw new Error('INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN'); + throw new DOMException( + `Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED.`, + DOMExceptionNameEnum.invalidStateError + ); } const lowerHeader = header.toLowerCase(); @@ -194,7 +187,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { return false; } if (this._sendFlag) { - throw new Error('INVALID_STATE_ERR: send flag is true'); + throw new DOMException('send flag is true', DOMExceptionNameEnum.invalidStateError); } this._requestHeaders[lowerHeader] = value; return true; @@ -264,11 +257,17 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { */ public send(data?: string): void { if (this.readyState != XMLHttpRequestReadyStateEnum.opened) { - throw new Error('INVALID_STATE_ERR: connection must be opened before send() is called'); + throw new DOMException( + 'connection must be opened before send() is called', + DOMExceptionNameEnum.invalidStateError + ); } if (this._sendFlag) { - throw new Error('INVALID_STATE_ERR: send has already been called'); + throw new DOMException( + 'send has already been called', + DOMExceptionNameEnum.invalidStateError + ); } let ssl = false; @@ -300,42 +299,19 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { break; default: - throw new Error('Protocol not supported.'); + throw new DOMException('Protocol not supported.', DOMExceptionNameEnum.notSupportedError); } // TODO: Security Issue. // Load files off the local filesystem (file://) if (local) { - if (this._settings.method !== 'GET') { - throw new Error('XMLHttpRequest: Only GET method is supported'); - } - - if (this._settings.async) { - FS.readFile(decodeURI(url.pathname.slice(1)), 'utf8', (error: Error, data: Buffer) => { - if (error) { - this._handleError(error); - } else { - this.status = 200; - this.responseText = data.toString(); - this._setState(XMLHttpRequestReadyStateEnum.done); - } - }); - } else { - try { - this.responseText = FS.readFileSync(decodeURI(url.pathname.slice(1)), 'utf8'); - this.status = 200; - this._setState(XMLHttpRequestReadyStateEnum.done); - } catch (error) { - this._handleError(error); - } - } - + this._sendLocalRequest(url); return; } // Default to port 80. If accessing localhost on another port be sure // To use http://localhost:port/path - const port = url.port || (ssl ? 443 : 80); + const port = Number(url.port) || (ssl ? 443 : 80); // Add query string if one is used const uri = url.pathname + (url.search ? url.search : ''); @@ -347,9 +323,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Set Basic Auth if necessary if (this._settings.user) { - if (typeof this._settings.password == 'undefined') { - this._settings.password = ''; - } + this._settings.password ??= ''; const authBuffer = Buffer.from(this._settings.user + ':' + this._settings.password); this._requestHeaders['authorization'] = 'Basic ' + authBuffer.toString('base64'); } @@ -371,10 +345,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._requestHeaders['content-length'] = 0; } - const options = { + const options: IXMLHttpRequestOptions = { host: host, port: port, - path: uri, + uri: uri, method: this._settings.method, headers: Object.assign(this._getDefaultRequestHeaders(), this._requestHeaders), agent: false, @@ -392,164 +366,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._errorFlag = false; // Handle async requests if (this._settings.async) { - // Use the proper protocol - const sendRequest = ssl ? HTTPS.request : HTTP.request; - - // Request is being sent, set send flag - this._sendFlag = true; - - // As per spec, this is called here for historical reasons. - this.dispatchEvent(new Event('readystatechange')); - - // Handler for the response - const responseHandler = (resp): void => { - // Set response var to the response we got back - // This is so it remains accessable outside this scope - this._response = resp; - - // Check for redirect - // @TODO Prevent looped redirects - if ( - this._response.statusCode === 302 || - this._response.statusCode === 303 || - this._response.statusCode === 307 - ) { - // Change URL to the redirect location - this._settings.url = this._response.headers.location; - const url = new URL(this._settings.url); - // Set host var in case it's used later - host = url.hostname; - // Options for the new request - const newOptions = { - hostname: url.hostname, - port: url.port, - path: url.pathname, - method: this._response.statusCode === 303 ? 'GET' : this._settings.method, - headers: Object.assign(this._getDefaultRequestHeaders(), this._requestHeaders), - rejectUnauthorized: true, - key: ssl ? SSL_KEY : null, - cert: ssl ? SSL_CERT : null - }; - - // Issue the new request - this._request = sendRequest(newOptions, responseHandler).on('error', errorHandler); - this._request.end(); - // @TODO Check if an XHR event needs to be fired here - return; - } - - if (this._response && this._response.setEncoding) { - this._response.setEncoding('utf8'); - } - - this._setState(XMLHttpRequestReadyStateEnum.headersRecieved); - this.status = this._response.statusCode; - - this._response.on('data', (chunk) => { - // Make sure there's some data - if (chunk) { - this.responseText += chunk; - } - // Don't emit state changes if the connection has been aborted. - if (this._sendFlag) { - this._setState(XMLHttpRequestReadyStateEnum.loading); - } - }); - - this._response.on('end', () => { - if (this._sendFlag) { - // The sendFlag needs to be set before setState is called. Otherwise, if we are chaining callbacks - // There can be a timing issue (the callback is called and a new call is made before the flag is reset). - this._sendFlag = false; - // Discard the 'end' event if the connection has been aborted - this._setState(XMLHttpRequestReadyStateEnum.done); - } - }); - - this._response.on('error', (error) => { - this._handleError(error); - }); - }; - - // Error handler for the request - const errorHandler = (error): void => { - this._handleError(error); - }; - - // Create the request - this._request = sendRequest(options, responseHandler).on('error', errorHandler); - - // Node 0.4 and later won't accept empty data. Make sure it's needed. - if (data) { - this._request.write(data); - } - - this._request.end(); - - this.dispatchEvent(new Event('loadstart')); + this._sendAsyncRequest(options, ssl, data); } else { - // Synchronous - // Create a temporary file for communication with the other Node process - const contentFile = '.node-xmlhttprequest-content-' + process.pid; - const syncFile = '.node-xmlhttprequest-sync-' + process.pid; - FS.writeFileSync(syncFile, '', 'utf8'); - - // The async request the other Node process executes - const execString = ` - const HTTP = require('http'); - const HTTPS = require('https'); - const FS = require('fs'); - const sendRequest = HTTP${ssl ? 'S' : ''}.request; - const options = ${JSON.stringify(options)}; - const request = sendRequest(options, (response) => { - let responseText = ''; - response.setEncoding('utf8'); - response.on('data', (chunk) => { - responseText += chunk; - }); - response.on('end', () => { - FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8'); - FS.unlinkSync('${syncFile}'); - }); - response.on('error', (error) => { - FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8').on('error', (error) => { - FS.writeFileSync('${contentFile}', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8'); - FS.unlinkSync('${syncFile}'); - }); - }); - }); - request.write(\`${JSON.stringify(data).slice(1, -1)}\`); - request.end(); - `.trim(); - - // Start the other Node Process, executing this string - ChildProcess.execFileSync(process.argv[0], ['-e', execString]); - - // If syncFile still exists, the request failed, if contentFile doesn't exist, the request failed. - if (FS.existsSync(syncFile) || !FS.existsSync(contentFile)) { - throw new Error('Synchronous request failed'); - } - - this.responseText = FS.readFileSync(contentFile, 'utf8'); - - // Remove the temporary file - FS.unlinkSync(contentFile); - - if (this.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR:/)) { - // If the file returned an error, handle it - const errorObj = this.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, ''); - this._handleError(errorObj, 503); - } else { - // If the file returned okay, parse its data and move to the DONE state - this.status = Number( - this.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:([0-9]*),.*/, '$1') - ); - this.responseText = this.responseText.replace( - /^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/, - '$1' - ); - this._setState(XMLHttpRequestReadyStateEnum.done); - } + this._sendSyncRequest(options, ssl, data); } } @@ -644,4 +463,295 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { cookie: this._ownerDocument.defaultView.document.cookie }; } + + /** + * + * @param options + * @param ssl + * @param data + */ + private _sendSyncRequest(options: IXMLHttpRequestOptions, ssl: boolean, data?: string): void { + // Synchronous + // Create a temporary file for communication with the other Node process + const contentFile = '.node-xml-http-request-content-' + process.pid; + const syncFile = '.node-xml-http-request-sync-' + process.pid; + FS.writeFileSync(syncFile, '', 'utf8'); + + // The async request the other Node process executes + const execString = ` + const HTTP = require('http'); + const HTTPS = require('https'); + const FS = require('fs'); + const sendRequest = HTTP${ssl ? 'S' : ''}.request; + const options = ${JSON.stringify(options)}; + const request = sendRequest(options, (response) => { + let responseText = ''; + let responseData = Buffer.alloc(0); + response.setEncoding('utf8'); + response.on('data', (chunk) => { + responseText += chunk; + responseData = Buffer.concat([responseData, Buffer.from(chunk)]); + }); + response.on('end', () => { + FS.writeFileSync('${contentFile}', JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, text: responseText, data: responseData.toString('base64')}}), 'utf8'); + FS.unlinkSync('${syncFile}'); + }); + response.on('error', (error) => { + FS.writeFileSync('${contentFile}', 'NODE-XML-HTTP-REQUEST-ERROR:' + JSON.stringify(error), 'utf8').on('error', (error) => { + FS.writeFileSync('${contentFile}', 'NODE-XML-HTTP-REQUEST-ERROR:' + JSON.stringify(error), 'utf8'); + FS.unlinkSync('${syncFile}'); + }); + }); + }); + request.write(\`${JSON.stringify(data).slice(1, -1).replace(/'/g, "\\'")}\`); + request.end(); + `.trim(); + + // Start the other Node Process, executing this string + ChildProcess.execFileSync(process.argv[0], ['-e', execString]); + + // If syncFile still exists, the request failed, if contentFile doesn't exist, the request failed. + if (FS.existsSync(syncFile) || !FS.existsSync(contentFile)) { + throw new DOMException('Synchronous request failed', DOMExceptionNameEnum.networkError); + } + this.responseURL = RelativeURL.getAbsoluteURL( + this._ownerDocument.defaultView.location, + this._settings.url + ).href; + const content = FS.readFileSync(contentFile, 'utf8'); + + // Remove the temporary file + FS.unlinkSync(contentFile); + + if (content.match(/^NODE-XML-HTTP-REQUEST-ERROR:/)) { + // If the file returned an error, handle it + const errorObj = JSON.parse(content.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, '')); + this._handleError(errorObj, 503); + } else { + // If the file returned okay, parse its data and move to the DONE state + const { data: responseObj } = JSON.parse(content); + + this._response = { statusCode: responseObj.statusCode, headers: responseObj.headers }; + this.status = responseObj.statusCode; + this.responseText = responseObj.text; + this.response = Buffer.from(responseObj.data, 'base64'); + + // Set Cookies. + if (this._response.headers['set-cookie']) { + // TODO: Bugs in CookieJar. + this._ownerDocument.defaultView.document.cookie = this._response.headers['set-cookie']; + } + + if ( + this._response.statusCode === 302 || + this._response.statusCode === 303 || + this._response.statusCode === 307 + ) { + const redirectUrl = RelativeURL.getAbsoluteURL( + this._ownerDocument.defaultView.location, + this._response.headers['location'] + ); + ssl = redirectUrl.protocol === 'https:'; + this._settings.url = redirectUrl.href; + this._sendSyncRequest( + Object.assign(options, { + host: redirectUrl.host, + uri: redirectUrl.pathname + (redirectUrl.search ?? ''), + port: redirectUrl.port || (ssl ? 443 : 80) + }), + ssl, + data + ); + } + + this._setState(XMLHttpRequestReadyStateEnum.done); + } + } + + /** + * + * @param options + * @param ssl + * @param data + */ + private _sendAsyncRequest(options: object, ssl: boolean, data?: string): void { + // Use the proper protocol + const sendRequest = ssl ? HTTPS.request : HTTP.request; + + // Request is being sent, set send flag + this._sendFlag = true; + + // As per spec, this is called here for historical reasons. + this.dispatchEvent(new Event('readystatechange')); + + // Handler for the response + const responseHandler = (resp): void => { + // Set response var to the response we got back + // This is so it remains accessable outside this scope + this._response = resp; + + // Check for redirect + // @TODO Prevent looped redirects + if ( + this._response.statusCode === 302 || + this._response.statusCode === 303 || + this._response.statusCode === 307 + ) { + // TODO: redirect url protocol change. + // Change URL to the redirect location + this._settings.url = this._response.headers.location; + // Parse the new URL. + const url = RelativeURL.getAbsoluteURL( + this._ownerDocument.defaultView.location, + this._settings.url + ); + + // Options for the new request + const newOptions = { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: this._response.statusCode === 303 ? 'GET' : this._settings.method, + headers: Object.assign(this._getDefaultRequestHeaders(), this._requestHeaders), + rejectUnauthorized: true, + key: ssl ? SSL_KEY : null, + cert: ssl ? SSL_CERT : null + }; + + // Issue the new request + this._request = sendRequest(newOptions, responseHandler).on('error', errorHandler); + this._request.end(); + // @TODO Check if an XHR event needs to be fired here + return; + } + + if (this._response && this._response.setEncoding) { + this._response.setEncoding('utf8'); + } + + this._setState(XMLHttpRequestReadyStateEnum.headersRecieved); + this.status = this._response.statusCode; + + // Initialize response. + this.response = new Uint8Array(0); + + this._response.on('data', (chunk) => { + // Make sure there's some data + if (chunk) { + this.response = Buffer.concat([this.response, Buffer.from(chunk)]); + } + // Don't emit state changes if the connection has been aborted. + if (this._sendFlag) { + this._setState(XMLHttpRequestReadyStateEnum.loading); + } + }); + + this._response.on('end', () => { + if (this._sendFlag) { + // The sendFlag needs to be set before setState is called. Otherwise, if we are chaining callbacks + // There can be a timing issue (the callback is called and a new call is made before the flag is reset). + this._sendFlag = false; + + // Redirect URL. + this.responseURL = RelativeURL.getAbsoluteURL( + this._ownerDocument.defaultView.location, + this._settings.url + ).href; + // Progress response. + this.responseText = this.response.toString(); + switch (this.responseType) { + case 'arraybuffer': + this.response = Buffer.from(this.response); + break; + case 'blob': + try { + this.response = new this._ownerDocument.defaultView.Blob([this.response]); + } catch (e) { + this.response = null; + } + break; + case 'document': + try { + this.response = new this._ownerDocument.defaultView.DOMParser().parseFromString( + this.response.toString(), + 'text/html' + ); + } catch (e) { + this.response = null; + } + break; + case 'json': + try { + this.response = JSON.parse(this.response.toString()); + } catch (e) { + this.response = null; + } + break; + case 'text': + case '': + default: + this.response = this.response.toString(); + break; + } + + // Discard the 'end' event if the connection has been aborted + this._setState(XMLHttpRequestReadyStateEnum.done); + } + }); + + this._response.on('error', (error) => { + this._handleError(error); + }); + }; + + // Error handler for the request + const errorHandler = (error): void => { + this._handleError(error); + }; + + // Create the request + this._request = sendRequest(options, responseHandler).on('error', errorHandler); + + // Node 0.4 and later won't accept empty data. Make sure it's needed. + if (data) { + this._request.write(data); + } + + this._request.end(); + + this.dispatchEvent(new Event('loadstart')); + } + + /** + * + * @param url + */ + private _sendLocalRequest(url: UrlObject): void { + if (this._settings.method !== 'GET') { + throw new DOMException( + 'XMLHttpRequest: Only GET method is supported', + DOMExceptionNameEnum.notSupportedError + ); + } + + if (this._settings.async) { + FS.readFile(decodeURI(url.pathname.slice(1)), 'utf8', (error: Error, data: Buffer) => { + if (error) { + this._handleError(error); + } else { + this.status = 200; + this.responseText = data.toString(); + this._setState(XMLHttpRequestReadyStateEnum.done); + } + }); + } else { + try { + this.responseText = FS.readFileSync(decodeURI(url.pathname.slice(1)), 'utf8'); + this.status = 200; + this._setState(XMLHttpRequestReadyStateEnum.done); + } catch (error) { + this._handleError(error); + } + } + } } From 7f5554300c2a2b75146b86b90fec574128b4dc38 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 16 Oct 2022 18:59:23 +0800 Subject: [PATCH 22/40] #463@minor: Found Bugs in CookieJar, add more support for syncRequest. --- packages/happy-dom/src/nodes/document/Document.ts | 8 +++++++- .../happy-dom/src/xml-http-request/IXMLHttpRequest.ts | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 packages/happy-dom/src/xml-http-request/IXMLHttpRequest.ts diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts index c930d6878..6942b7107 100644 --- a/packages/happy-dom/src/nodes/document/Document.ts +++ b/packages/happy-dom/src/nodes/document/Document.ts @@ -265,7 +265,13 @@ export default class Document extends Node implements IDocument { * * @param cookie Cookie string. */ - public set cookie(cookie: string) { + public set cookie(cookie: string | string[]) { + if (Array.isArray(cookie)) { + for (const c of cookie) { + this._cookie = CookieUtility.getCookieString(this.defaultView.location, this._cookie, c); + } + return; + } this._cookie = CookieUtility.getCookieString(this.defaultView.location, this._cookie, cookie); } diff --git a/packages/happy-dom/src/xml-http-request/IXMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/IXMLHttpRequest.ts new file mode 100644 index 000000000..52b268e07 --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/IXMLHttpRequest.ts @@ -0,0 +1,11 @@ +export interface IXMLHttpRequestOptions { + host: string; + port: number; + uri: string; + method: string; + headers: object; + agent: boolean; + rejectUnauthorized: boolean; + key: string; + cert: string; +} From d588d3baa7e87b5f6c2bfecf59ea817f1b932bf6 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 16 Oct 2022 19:35:14 +0800 Subject: [PATCH 23/40] #463@minor: Found Bugs in CookieJar, add more support for syncRequest. --- .../src/exception/DOMExceptionNameEnum.ts | 3 +- .../src/xml-http-request/XMLHttpRequest.ts | 44 +++++++++++-------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts b/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts index 8b8d9900a..0dacbb4c0 100644 --- a/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts +++ b/packages/happy-dom/src/exception/DOMExceptionNameEnum.ts @@ -10,6 +10,7 @@ enum DOMExceptionNameEnum { notFoundError = 'NotFoundError', securityError = 'SecurityError', networkError = 'NetworkError', - domException = 'DOMException' + domException = 'DOMException', + invalidAccessError = 'InvalidAccessError' } export default DOMExceptionNameEnum; diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 58ad37a3d..2c0b40ff9 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -155,6 +155,13 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if (FORBIDDEN_REQUEST_METHODS.includes(upperMethod)) { throw new DOMException('Request method not allowed', DOMExceptionNameEnum.securityError); } + // Check responseType. + if (upperMethod === 'GET' && !!this.responseType && this.responseType !== 'text') { + throw new DOMException( + `Failed to execute 'open' on 'XMLHttpRequest': Synchronous requests from a document must not set a response type.`, + DOMExceptionNameEnum.invalidAccessError + ); + } this._settings = { method: upperMethod, @@ -534,14 +541,15 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._response = { statusCode: responseObj.statusCode, headers: responseObj.headers }; this.status = responseObj.statusCode; this.responseText = responseObj.text; - this.response = Buffer.from(responseObj.data, 'base64'); + // Sync response is always text. + this.response = Buffer.from(responseObj.data, 'base64').toString('utf8'); // Set Cookies. if (this._response.headers['set-cookie']) { // TODO: Bugs in CookieJar. this._ownerDocument.defaultView.document.cookie = this._response.headers['set-cookie']; } - + // Redirect. if ( this._response.statusCode === 302 || this._response.statusCode === 303 || @@ -553,11 +561,13 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ); ssl = redirectUrl.protocol === 'https:'; this._settings.url = redirectUrl.href; + // Recursive call. this._sendSyncRequest( Object.assign(options, { host: redirectUrl.host, uri: redirectUrl.pathname + (redirectUrl.search ?? ''), - port: redirectUrl.port || (ssl ? 443 : 80) + port: redirectUrl.port || (ssl ? 443 : 80), + method: this._response.statusCode === 303 ? 'GET' : this._settings.method }), ssl, data @@ -601,26 +611,22 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Change URL to the redirect location this._settings.url = this._response.headers.location; // Parse the new URL. - const url = RelativeURL.getAbsoluteURL( + const redirectUrl = RelativeURL.getAbsoluteURL( this._ownerDocument.defaultView.location, this._settings.url ); - - // Options for the new request - const newOptions = { - hostname: url.hostname, - port: url.port, - path: url.pathname, - method: this._response.statusCode === 303 ? 'GET' : this._settings.method, - headers: Object.assign(this._getDefaultRequestHeaders(), this._requestHeaders), - rejectUnauthorized: true, - key: ssl ? SSL_KEY : null, - cert: ssl ? SSL_CERT : null - }; - + ssl = redirectUrl.protocol === 'https:'; // Issue the new request - this._request = sendRequest(newOptions, responseHandler).on('error', errorHandler); - this._request.end(); + this._sendAsyncRequest( + Object.assign(options, { + host: redirectUrl.hostname, + port: redirectUrl.port, + uri: redirectUrl.pathname + (redirectUrl.search ?? ''), + method: this._response.statusCode === 303 ? 'GET' : this._settings.method + }), + ssl, + data + ); // @TODO Check if an XHR event needs to be fired here return; } From 3431208b19120a856cbb8cb25073e11df0b2a7d4 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 16 Oct 2022 20:09:42 +0800 Subject: [PATCH 24/40] #463@minor: Continue Fixes. --- .../src/xml-http-request/IXMLHttpRequest.ts | 2 +- .../happy-dom/src/xml-http-request/XMLHttpRequest.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/happy-dom/src/xml-http-request/IXMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/IXMLHttpRequest.ts index 52b268e07..850e84eba 100644 --- a/packages/happy-dom/src/xml-http-request/IXMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/IXMLHttpRequest.ts @@ -1,7 +1,7 @@ export interface IXMLHttpRequestOptions { host: string; port: number; - uri: string; + path: string; method: string; headers: object; agent: boolean; diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 2c0b40ff9..aaa4e4743 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -156,7 +156,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { throw new DOMException('Request method not allowed', DOMExceptionNameEnum.securityError); } // Check responseType. - if (upperMethod === 'GET' && !!this.responseType && this.responseType !== 'text') { + if (!async && !!this.responseType && this.responseType !== 'text') { throw new DOMException( `Failed to execute 'open' on 'XMLHttpRequest': Synchronous requests from a document must not set a response type.`, DOMExceptionNameEnum.invalidAccessError @@ -355,7 +355,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { const options: IXMLHttpRequestOptions = { host: host, port: port, - uri: uri, + path: uri, method: this._settings.method, headers: Object.assign(this._getDefaultRequestHeaders(), this._requestHeaders), agent: false, @@ -565,7 +565,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._sendSyncRequest( Object.assign(options, { host: redirectUrl.host, - uri: redirectUrl.pathname + (redirectUrl.search ?? ''), + path: redirectUrl.pathname + (redirectUrl.search ?? ''), port: redirectUrl.port || (ssl ? 443 : 80), method: this._response.statusCode === 303 ? 'GET' : this._settings.method }), @@ -584,7 +584,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param ssl * @param data */ - private _sendAsyncRequest(options: object, ssl: boolean, data?: string): void { + private _sendAsyncRequest(options: IXMLHttpRequestOptions, ssl: boolean, data?: string): void { // Use the proper protocol const sendRequest = ssl ? HTTPS.request : HTTP.request; @@ -621,7 +621,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { Object.assign(options, { host: redirectUrl.hostname, port: redirectUrl.port, - uri: redirectUrl.pathname + (redirectUrl.search ?? ''), + path: redirectUrl.pathname + (redirectUrl.search ?? ''), method: this._response.statusCode === 303 ? 'GET' : this._settings.method }), ssl, @@ -716,7 +716,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { }; // Create the request - this._request = sendRequest(options, responseHandler).on('error', errorHandler); + this._request = sendRequest(options, responseHandler).on('error', errorHandler); // Node 0.4 and later won't accept empty data. Make sure it's needed. if (data) { From a4b22eaf7d0472b8794ac03e9c685283b1dd4b68 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sun, 16 Oct 2022 21:50:00 +0800 Subject: [PATCH 25/40] #463@minor: Continue Fixes. --- .../src/fetch/ResourceFetchHandler.ts | 18 +++++++----------- .../src/xml-http-request/XMLHttpRequest.ts | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/happy-dom/src/fetch/ResourceFetchHandler.ts b/packages/happy-dom/src/fetch/ResourceFetchHandler.ts index 5e83c2c84..a5db65844 100644 --- a/packages/happy-dom/src/fetch/ResourceFetchHandler.ts +++ b/packages/happy-dom/src/fetch/ResourceFetchHandler.ts @@ -33,21 +33,17 @@ export default class ResourceFetchHandler { public static fetchSync(document: IDocument, url: string): string { // We want to only load SyncRequest when it is needed to improve performance and not have direct dependencies to server side packages. const absoluteURL = RelativeURL.getAbsoluteURL(document.defaultView.location, url).href; - const syncRequest = require('sync-request'); - const response = syncRequest('GET', absoluteURL, { - headers: { - 'user-agent': document.defaultView.navigator.userAgent, - cookie: document.defaultView.document.cookie, - referer: document.defaultView.location.origin - } - }); - if (response.isError()) { + const xhr = new document.defaultView.XMLHttpRequest(); + xhr.open('GET', absoluteURL, false); + xhr.send(); + + if (xhr.status !== 200) { throw new DOMException( - `Failed to perform request to "${absoluteURL}". Status code: ${response.statusCode}` + `Failed to perform request to "${absoluteURL}". Status code: ${xhr.status}` ); } - return response.getBody().toString(); + return xhr.responseText; } } diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index aaa4e4743..d7750ebe2 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -551,6 +551,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } // Redirect. if ( + this._response.statusCode === 301 || this._response.statusCode === 302 || this._response.statusCode === 303 || this._response.statusCode === 307 @@ -567,7 +568,11 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { host: redirectUrl.host, path: redirectUrl.pathname + (redirectUrl.search ?? ''), port: redirectUrl.port || (ssl ? 443 : 80), - method: this._response.statusCode === 303 ? 'GET' : this._settings.method + method: this._response.statusCode === 303 ? 'GET' : this._settings.method, + headers: Object.assign(options.headers, { + referer: redirectUrl.origin, + host: redirectUrl.host + }) }), ssl, data @@ -603,6 +608,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Check for redirect // @TODO Prevent looped redirects if ( + this._response.statusCode === 301 || this._response.statusCode === 302 || this._response.statusCode === 303 || this._response.statusCode === 307 @@ -615,6 +621,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._ownerDocument.defaultView.location, this._settings.url ); + this._settings.url = redirectUrl.href; ssl = redirectUrl.protocol === 'https:'; // Issue the new request this._sendAsyncRequest( @@ -622,7 +629,11 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { host: redirectUrl.hostname, port: redirectUrl.port, path: redirectUrl.pathname + (redirectUrl.search ?? ''), - method: this._response.statusCode === 303 ? 'GET' : this._settings.method + method: this._response.statusCode === 303 ? 'GET' : this._settings.method, + headers: Object.assign(options.headers, { + referer: redirectUrl.origin, + host: redirectUrl.host + }) }), ssl, data From f67f67d7b31bbf1288973af56a2b5a5163c28e62 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 19 Oct 2022 01:07:46 +0200 Subject: [PATCH 26/40] #463@trivial: Disables HTTP request local filesystem by default and improves the functionality of XMLHttpRequest. --- packages/happy-dom/README.md | 47 +- .../html-link-element/HTMLLinkElement.ts | 9 +- .../html-script-element/HTMLScriptElement.ts | 2 +- .../html-script-element/ScriptUtility.ts | 8 + .../happy-dom/src/window/IHappyDOMSettings.ts | 9 + packages/happy-dom/src/window/IWindow.ts | 2 + packages/happy-dom/src/window/Window.ts | 18 +- .../src/xml-http-request/IXMLHttpRequest.ts | 11 - .../src/xml-http-request/XMLHttpRequest.ts | 420 +++++++++++------- 9 files changed, 353 insertions(+), 173 deletions(-) create mode 100644 packages/happy-dom/src/window/IHappyDOMSettings.ts delete mode 100644 packages/happy-dom/src/xml-http-request/IXMLHttpRequest.ts diff --git a/packages/happy-dom/README.md b/packages/happy-dom/README.md index 0e9f937c0..d192a1da5 100644 --- a/packages/happy-dom/README.md +++ b/packages/happy-dom/README.md @@ -211,8 +211,6 @@ console.log(document.body.querySelector('div').getInnerHTML({ includeShadowRoots ## Additional Features -Happy DOM exposes two functions that may be useful when working with asynchrounous code. - **whenAsyncComplete()** Returns a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) that is resolved when all async tasks has been completed. @@ -250,6 +248,51 @@ Sets the property `window.innerHeight` and dispatches a "resize" event. window.happyDOM.setInnerHeight(768); ``` +## Settings + +Settings can be sent to the constructor or by setting them on the "window.happyDOM.settings" property. + +Set by constructor: +```javascript +const window = new Window({ + innerWidth: 1024, + innerHeight: 768, + url: 'http://localhost:8080', + settings: { + disableJavaScriptFileLoading: true, + disableJavaScriptEvaluation: true, + disableCSSFileLoading: true, + enableFileSystemHttpRequests: true + } +}); +``` + +Set by property: +```javascript +const window = new Window(); + +window.happyDOM.settings.disableJavaScriptFileLoading = true; +window.happyDOM.settings.disableJavaScriptEvaluation = true; +window.happyDOM.settings.disableCSSFileLoading = true; +window.happyDOM.settings.enableFileSystemHttpRequests = true; +``` + +**disableJavaScriptFileLoading** + +Set it to "true" to disable JavaScript file loading. Defaults to "false". + +**disableJavaScriptEvaluation** + +Set it to "true" to completely disable JavaScript evaluation. Defaults to "false". + +**disableCSSFileLoading** + +Set it to "true" to disable CSS file loading using the HTMLLinkElement. Defaults to "false". + +**enableFileSystemHttpRequests** + +Set it to "true" to enable file system HTTP requests using XMLHttpRequest. Defaults to "false". + # Performance diff --git a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts index 22ccefadf..9fb296d42 100644 --- a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts +++ b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts @@ -196,7 +196,8 @@ export default class HTMLLinkElement extends HTMLElement implements IHTMLLinkEle href !== null && rel && rel.toLowerCase() === 'stylesheet' && - this.isConnected + this.isConnected && + !this.ownerDocument.defaultView.happyDOM.settings.disableCSSFileLoading ) { (this.ownerDocument)._readyStateManager.startTask(); ResourceFetchHandler.fetch(this.ownerDocument, href) @@ -242,7 +243,11 @@ export default class HTMLLinkElement extends HTMLElement implements IHTMLLinkEle super._connectToNode(parentNode); - if (isConnected !== isParentConnected && this._evaluateCSS) { + if ( + isConnected !== isParentConnected && + this._evaluateCSS && + !this.ownerDocument.defaultView.happyDOM.settings.disableCSSFileLoading + ) { const href = this.getAttributeNS(null, 'href'); const rel = this.getAttributeNS(null, 'rel'); diff --git a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts index 8115610a0..6affe075d 100644 --- a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +++ b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts @@ -193,7 +193,7 @@ export default class HTMLScriptElement extends HTMLElement implements IHTMLScrip if (src !== null) { ScriptUtility.loadExternalScript(this); - } else { + } else if (!this.ownerDocument.defaultView.happyDOM.settings.disableJavaScriptEvaluation) { const textContent = this.textContent; const type = this.getAttributeNS(null, 'type'); if ( diff --git a/packages/happy-dom/src/nodes/html-script-element/ScriptUtility.ts b/packages/happy-dom/src/nodes/html-script-element/ScriptUtility.ts index 154801a3b..4b71f3041 100644 --- a/packages/happy-dom/src/nodes/html-script-element/ScriptUtility.ts +++ b/packages/happy-dom/src/nodes/html-script-element/ScriptUtility.ts @@ -18,6 +18,14 @@ export default class ScriptUtility { public static async loadExternalScript(element: HTMLScriptElement): Promise { const src = element.getAttributeNS(null, 'src'); const async = element.getAttributeNS(null, 'async') !== null; + + if ( + element.ownerDocument.defaultView.happyDOM.settings.disableJavaScriptFileLoading || + element.ownerDocument.defaultView.happyDOM.settings.disableJavaScriptEvaluation + ) { + return; + } + if (async) { let code = null; (element.ownerDocument)._readyStateManager.startTask(); diff --git a/packages/happy-dom/src/window/IHappyDOMSettings.ts b/packages/happy-dom/src/window/IHappyDOMSettings.ts new file mode 100644 index 000000000..cb19d869f --- /dev/null +++ b/packages/happy-dom/src/window/IHappyDOMSettings.ts @@ -0,0 +1,9 @@ +/** + * Happy DOM settings. + */ +export default interface IHappyDOMSettings { + disableJavaScriptEvaluation: boolean; + disableJavaScriptFileLoading: boolean; + disableCSSFileLoading: boolean; + enableFileSystemHttpRequests: boolean; +} diff --git a/packages/happy-dom/src/window/IWindow.ts b/packages/happy-dom/src/window/IWindow.ts index 6013eb5eb..0b0e304ca 100644 --- a/packages/happy-dom/src/window/IWindow.ts +++ b/packages/happy-dom/src/window/IWindow.ts @@ -101,6 +101,7 @@ import Attr from '../nodes/attr/Attr'; import { Performance } from 'perf_hooks'; import IElement from '../nodes/element/IElement'; import ProcessingInstruction from '../nodes/processing-instruction/ProcessingInstruction'; +import IHappyDOMSettings from './IHappyDOMSettings'; /** * Window without dependencies to server side specific packages. @@ -113,6 +114,7 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { asyncTaskManager: AsyncTaskManager; setInnerWidth: (width: number) => void; setInnerHeight: (height: number) => void; + settings: IHappyDOMSettings; }; // Global classes diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index 0a87bc082..32910eccd 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -112,6 +112,7 @@ import IDocument from '../nodes/document/IDocument'; import Attr from '../nodes/attr/Attr'; import IElement from '../nodes/element/IElement'; import ProcessingInstruction from '../nodes/processing-instruction/ProcessingInstruction'; +import IHappyDOMSettings from './IHappyDOMSettings'; const ORIGINAL_SET_TIMEOUT = setTimeout; const ORIGINAL_CLEAR_TIMEOUT = clearTimeout; @@ -145,6 +146,11 @@ export default class Window extends EventTarget implements IWindow { (this.innerHeight) = height; this.dispatchEvent(new Event('resize')); } + }, + settings: { + disableJavaScriptEvaluation: false, + disableCSSEvaluation: false, + enableFileSystemHttpRequests: false } }; @@ -349,8 +355,14 @@ export default class Window extends EventTarget implements IWindow { * @param [options.innerWidth] Inner width. * @param [options.innerHeight] Inner height. * @param [options.url] URL. + * @param [options.settings] Settings. */ - constructor(options?: { innerWidth?: number; innerHeight?: number; url?: string }) { + constructor(options?: { + innerWidth?: number; + innerHeight?: number; + url?: string; + settings?: IHappyDOMSettings; + }) { super(); this.innerWidth = options?.innerWidth ? options.innerWidth : 0; @@ -360,6 +372,10 @@ export default class Window extends EventTarget implements IWindow { this.location.href = options.url; } + if (options?.settings) { + this.happyDOM.settings = Object.assign(this.happyDOM.settings, options.settings); + } + this._setTimeout = ORIGINAL_SET_TIMEOUT; this._clearTimeout = ORIGINAL_CLEAR_TIMEOUT; this._setInterval = ORIGINAL_SET_INTERVAL; diff --git a/packages/happy-dom/src/xml-http-request/IXMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/IXMLHttpRequest.ts deleted file mode 100644 index 850e84eba..000000000 --- a/packages/happy-dom/src/xml-http-request/IXMLHttpRequest.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface IXMLHttpRequestOptions { - host: string; - port: number; - path: string; - method: string; - headers: object; - agent: boolean; - rejectUnauthorized: boolean; - key: string; - cert: string; -} diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index d7750ebe2..87b236cd5 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -11,11 +11,59 @@ import XMLHttpRequestUpload from './XMLHttpRequestUpload'; import DOMException from '../exception/DOMException'; import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum'; import { UrlObject } from 'url'; -import { IXMLHttpRequestOptions } from './IXMLHttpRequest'; -const SSL_CERT = ``; - -const SSL_KEY = ``; +// SSL certificates generated for Happy DOM to be able to perform HTTPS requests: + +const SSL_CERT = `-----BEGIN CERTIFICATE----- +MIIDYzCCAkugAwIBAgIUJRKB/H66hpet1VfUlm0CiXqePA4wDQYJKoZIhvcNAQEL +BQAwQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVNYWxt +bzESMBAGA1UECgwJSGFwcHkgRE9NMB4XDTIyMTAxMTIyMDM0OVoXDTMyMTAwODIy +MDM0OVowQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVN +YWxtbzESMBAGA1UECgwJSGFwcHkgRE9NMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAqerQSQEg/SxVxRiwlItithr5e5EMZo1nsqt/xOxagbmpW3IEmt0j +bpbH7iEF4DDEo7KAOwUCOwVWeFxRoag8lG2ax48wrgjlCna45XDn0Xeg1ARajL04 +gs46HZ0VrzIloVGfln0zgt/Vum5BNqs9Oc5fQoBmoP3cAn3dn4ZVcP0AKthtcyPl +q2DuNRN0PV0D2RtMSiAy9l1Ko6N5x+sAeClDyOL+sTDLngZBVeZyOKt9Id15S8Zt +XtA6VMgHnnF3jChn7pag77rsd/y5iANAVNZYqRl+Eg7xaDcsvbgH46UBOrBcB39Q +tTh5Mtjoxep5e3ZDFG+kQ1HUE+iz5O5n0wIDAQABo1MwUTAdBgNVHQ4EFgQU69s9 +YSobG/m2SN4L/7zTaF7iDbwwHwYDVR0jBBgwFoAU69s9YSobG/m2SN4L/7zTaF7i +DbwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAi/WUXx2oal8L +YnPlIuKfh49n/K18wXSYG//oFYwxfVxqpYH8hUiXVm/GUcXCxS++hUkaKLqXmH9q +MKJiCrZr3vS+2nsBKopkICu/TLdROl0sAI9lByfnEbfSAzjxe1IWJdK8NdY0y5m5 +9pEr/URVIAp/CxrneyASb4q0Jg5To3FR7vYc+2X6wZn0MundKMg6Dp9/A37jiF3l +Tt/EJp299YZcsUzh+LnRuggRjnoOVu1aLcLFlaUiwZfy9m8mLG6B/mdW/qNzNMh9 +Oqvg1zfGdpz/4D/2UUUBn6pq1vbsoAaF3OesoA3mfDcegDf/H9woJlpT0Wql+e68 +Y3FblSokcA== +-----END CERTIFICATE-----`; + +const SSL_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCp6tBJASD9LFXF +GLCUi2K2Gvl7kQxmjWeyq3/E7FqBualbcgSa3SNulsfuIQXgMMSjsoA7BQI7BVZ4 +XFGhqDyUbZrHjzCuCOUKdrjlcOfRd6DUBFqMvTiCzjodnRWvMiWhUZ+WfTOC39W6 +bkE2qz05zl9CgGag/dwCfd2fhlVw/QAq2G1zI+WrYO41E3Q9XQPZG0xKIDL2XUqj +o3nH6wB4KUPI4v6xMMueBkFV5nI4q30h3XlLxm1e0DpUyAeecXeMKGfulqDvuux3 +/LmIA0BU1lipGX4SDvFoNyy9uAfjpQE6sFwHf1C1OHky2OjF6nl7dkMUb6RDUdQT +6LPk7mfTAgMBAAECggEAKkwTkTjAt4UjzK56tl+EMQTB+ep/hb/JgoaChci4Nva6 +m9LkJpDJ0yuhlTuPNOGu8XjrxsVWas7HWarRf0Zb3i7yip6wZYI9Ub+AA015x4DZ +/i0fRU2NFbK0cM67qSL4jxG8gj+kZP3HPGNZxHwX/53JxMolwgmvjMc8NgvAlSFd +NnV9h4xtbhUh1NGS5zmP3iU2rwnE8JrIEzwy6axLom7nekAgkdcbAr0UoBs8gcgH +aYNhU4Gz3tGcZZ0IXAfT/bJIH1Ko8AGv4pssWc3BXcmmNdm/+kzvHIxEIV7Qegmo +XG1ZyZCyD/0b4/3e8ySDBEDqwR+HeyTW2isWG2agAQKBgQDp44aTwr3dkIXY30xv +FPfUOipg/B49dWnffYJ9MWc1FT9ijNPAngWSk0EIiEQIazICcUBI4Yji6/KeyqLJ +GdLpDi1CkKqtyh73mjELinYp3EUQgEa77aQogGa2+nMOVfu+O5CtloUrv/A18jX3 ++VEyaEASK0fWmnSI0OdlxQHIAQKBgQC5+xOls2F3MlKASvWRLlnW1wHqlDTtVoYg +5Nh8syZH4Ci2UH8tON3A5/7SWNM0t1cgV6Cw4zW8Z2spgIT/W0iYYrQ4hHL1xdCu ++CxL1km4Gy8Uwpsd+KdFahFqF/XTmLzW0HXLxWSK0fTwmdV0SFrKF3MXfTCU2AeZ +jJoMFb6P0wKBgQC3Odw6s0vkYAzLGhuZxfZkVvDOK5RRF0NKpttr0iEFL9EJFkPo +2KKK8jr3QTDy229BBJGUxsJi6u6VwS8HlehpVQbV59kd7oKV/EBBx0XMg1fDlopT +PNbmN7i/zbIG4AsoOyebJZjL7kBzMn1e9vzKHWtcEHXlw/hZGja8vjooAQKBgAeg +xK2HLfg1mCyq5meN/yFQsENu0LzrT5UJzddPgcJw7zqLEqxIKNBAs7Ls8by3yFsL +PQwERa/0jfCl1M6kb9XQNpQa2pw6ANUsWKTDpUJn2wZ+9N3F1RaDwzMWyH5lRVmK +M0qoTfdjpSg5Jwgd75taWt4bxGJWeflSSv8z5R0BAoGAWL8c527AbeBvx2tOYKkD +2TFranvANNcoMrbeviZSkkGvMNDP3p8b6juJwXOIeWNr8q4vFgCzLmq6d1/9gYm2 +3XJwwyD0LKlqzkBrrKU47qrnmMosUrIRlrAzd3HbShOptxc6Iz2apSaUDKGKXkaw +gl5OpEjeliU7Mus0BVS858g= +-----END PRIVATE KEY-----`; // These headers are not user setable. // The following are allowed but banned in the spec: @@ -69,7 +117,6 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { public responseXML = ''; public responseURL = ''; public response = null; - public _responseType = ''; public status: number = null; public statusText: string = null; public upload: XMLHttpRequestUpload = new XMLHttpRequestUpload(); @@ -82,6 +129,8 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { private _sendFlag = false; private _errorFlag = false; private _abortedFlag = false; + private _responseType = ''; + private _asyncTaskID: number = null; private _settings: { method: string; url: string; @@ -263,6 +312,98 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param data Optional data to send as request body. */ public send(data?: string): void { + this._sendRequest(data); + } + + /** + * Aborts a request. + */ + public abort(): void { + if (this._request) { + this._request.abort(); + this._request = null; + } + + this._requestHeaders = {}; + this.responseText = ''; + this.responseXML = ''; + + this._errorFlag = this._abortedFlag = true; + if ( + this.readyState !== XMLHttpRequestReadyStateEnum.unsent && + (this.readyState !== XMLHttpRequestReadyStateEnum.opened || this._sendFlag) && + this.readyState !== XMLHttpRequestReadyStateEnum.done + ) { + this._sendFlag = false; + this._setState(XMLHttpRequestReadyStateEnum.done); + } + this.readyState = XMLHttpRequestReadyStateEnum.unsent; + + if (this._asyncTaskID !== null) { + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._asyncTaskID); + } + } + + /** + * Changes readyState and calls onreadystatechange. + * + * @param state + */ + private _setState(state: XMLHttpRequestReadyStateEnum): void { + if ( + this.readyState === state || + (this.readyState === XMLHttpRequestReadyStateEnum.unsent && this._abortedFlag) + ) { + return; + } + + this.readyState = state; + + if ( + this._settings.async || + this.readyState < XMLHttpRequestReadyStateEnum.opened || + this.readyState === XMLHttpRequestReadyStateEnum.done + ) { + this.dispatchEvent(new Event('readystatechange')); + } + + if (this.readyState === XMLHttpRequestReadyStateEnum.done) { + let fire: Event; + + if (this._abortedFlag) { + fire = new Event('abort'); + } else if (this._errorFlag) { + fire = new Event('error'); + } else { + fire = new Event('load'); + } + + this.dispatchEvent(fire); + this.dispatchEvent(new Event('loadend')); + } + } + + /** + * Default request headers. + * + * @returns Default request headers. + */ + private _getDefaultRequestHeaders(): { [key: string]: string } { + return { + accept: '*/*', + referer: this._ownerDocument.defaultView.location.origin, + 'user-agent': this._ownerDocument.defaultView.navigator.userAgent, + cookie: this._ownerDocument.defaultView.document.cookie + }; + } + + /** + * Sends a request. + * + * @param [data] Optional data to send as request body. + * @returns Promise that resolves when the request is done. + */ + private async _sendRequest(data?: string): Promise { if (this.readyState != XMLHttpRequestReadyStateEnum.opened) { throw new DOMException( 'connection must be opened before send() is called', @@ -277,14 +418,16 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ); } - let ssl = false; - let local = false; const url = RelativeURL.getAbsoluteURL( this._ownerDocument.defaultView.location, this._settings.url ); + let ssl = false; + let local = false; let host; + this._asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask(); + // Determine the server switch (url.protocol) { case 'https:': @@ -309,10 +452,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { throw new DOMException('Protocol not supported.', DOMExceptionNameEnum.notSupportedError); } - // TODO: Security Issue. // Load files off the local filesystem (file://) if (local) { - this._sendLocalRequest(url); + await this._sendLocalRequest(url); + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._asyncTaskID); return; } @@ -352,7 +495,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._requestHeaders['content-length'] = 0; } - const options: IXMLHttpRequestOptions = { + const options: HTTPS.RequestOptions = { host: host, port: port, path: uri, @@ -371,113 +514,25 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Reset error flag this._errorFlag = false; + // Handle async requests if (this._settings.async) { - this._sendAsyncRequest(options, ssl, data); + await this._sendAsyncRequest(options, ssl, data); } else { this._sendSyncRequest(options, ssl, data); } - } - - /** - * Aborts a request. - */ - public abort(): void { - if (this._request) { - this._request.abort(); - this._request = null; - } - this._requestHeaders = {}; - this.responseText = ''; - this.responseXML = ''; - - this._errorFlag = this._abortedFlag = true; - if ( - this.readyState !== XMLHttpRequestReadyStateEnum.unsent && - (this.readyState !== XMLHttpRequestReadyStateEnum.opened || this._sendFlag) && - this.readyState !== XMLHttpRequestReadyStateEnum.done - ) { - this._sendFlag = false; - this._setState(XMLHttpRequestReadyStateEnum.done); - } - this.readyState = XMLHttpRequestReadyStateEnum.unsent; - } - - /** - * Called when an error is encountered to deal with it. - * - * @param error Error object. - * @param status HTTP status code to use rather than the default (0) for XHR errors. - */ - private _handleError(error: Error | string, status = 0): void { - this.status = status; - this.statusText = error.toString(); - this.responseText = error instanceof Error ? error.stack : ''; - this._errorFlag = true; - this._setState(XMLHttpRequestReadyStateEnum.done); - } - - /** - * Changes readyState and calls onreadystatechange. - * - * @param state - */ - private _setState(state: XMLHttpRequestReadyStateEnum): void { - if ( - this.readyState === state || - (this.readyState === XMLHttpRequestReadyStateEnum.unsent && this._abortedFlag) - ) { - return; - } - - this.readyState = state; - - if ( - this._settings.async || - this.readyState < XMLHttpRequestReadyStateEnum.opened || - this.readyState === XMLHttpRequestReadyStateEnum.done - ) { - this.dispatchEvent(new Event('readystatechange')); - } - - if (this.readyState === XMLHttpRequestReadyStateEnum.done) { - let fire: Event; - - if (this._abortedFlag) { - fire = new Event('abort'); - } else if (this._errorFlag) { - fire = new Event('error'); - } else { - fire = new Event('load'); - } - - this.dispatchEvent(fire); - this.dispatchEvent(new Event('loadend')); - } - } - - /** - * Default request headers. - * - * @returns Default request headers. - */ - private _getDefaultRequestHeaders(): { [key: string]: string } { - return { - accept: '*/*', - referer: this._ownerDocument.defaultView.location.origin, - 'user-agent': this._ownerDocument.defaultView.navigator.userAgent, - cookie: this._ownerDocument.defaultView.document.cookie - }; + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._asyncTaskID); } /** + * Sends a synchronous request. * * @param options * @param ssl * @param data */ - private _sendSyncRequest(options: IXMLHttpRequestOptions, ssl: boolean, data?: string): void { + private _sendSyncRequest(options: HTTPS.RequestOptions, ssl: boolean, data?: string): void { // Synchronous // Create a temporary file for communication with the other Node process const contentFile = '.node-xml-http-request-content-' + process.pid; @@ -533,7 +588,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if (content.match(/^NODE-XML-HTTP-REQUEST-ERROR:/)) { // If the file returned an error, handle it const errorObj = JSON.parse(content.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, '')); - this._handleError(errorObj, 503); + this._onError(errorObj, 503); } else { // If the file returned okay, parse its data and move to the DONE state const { data: responseObj } = JSON.parse(content); @@ -584,26 +639,66 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** + * Sends an async request. * * @param options * @param ssl * @param data */ - private _sendAsyncRequest(options: IXMLHttpRequestOptions, ssl: boolean, data?: string): void { - // Use the proper protocol - const sendRequest = ssl ? HTTPS.request : HTTP.request; + private _sendAsyncRequest( + options: HTTPS.RequestOptions, + ssl: boolean, + data?: string + ): Promise { + return new Promise((resolve) => { + // Use the proper protocol + const sendRequest = ssl ? HTTPS.request : HTTP.request; + + // Request is being sent, set send flag + this._sendFlag = true; + + // As per spec, this is called here for historical reasons. + this.dispatchEvent(new Event('readystatechange')); + + // Create the request + this._request = sendRequest(options, async (response: HTTP.IncomingMessage) => { + await this._onAsyncResponse(response, options, ssl, data); + resolve(); + }).on('error', (error: Error) => { + this._onError(error); + resolve(); + }); - // Request is being sent, set send flag - this._sendFlag = true; + // Node 0.4 and later won't accept empty data. Make sure it's needed. + if (data) { + this._request.write(data); + } + + this._request.end(); - // As per spec, this is called here for historical reasons. - this.dispatchEvent(new Event('readystatechange')); + this.dispatchEvent(new Event('loadstart')); + }); + } - // Handler for the response - const responseHandler = (resp): void => { + /** + * Handles an async response. + * + * @param options Options. + * @param ssl SSL. + * @param data Data. + * @param response Response. + * @returns Promise. + */ + private _onAsyncResponse( + response: HTTP.IncomingMessage, + options: HTTPS.RequestOptions, + ssl: boolean, + data?: string + ): Promise { + return new Promise((resolve) => { // Set response var to the response we got back // This is so it remains accessable outside this scope - this._response = resp; + this._response = response; // Check for redirect // @TODO Prevent looped redirects @@ -714,61 +809,74 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Discard the 'end' event if the connection has been aborted this._setState(XMLHttpRequestReadyStateEnum.done); } + + resolve(); }); this._response.on('error', (error) => { - this._handleError(error); + this._onError(error); + resolve(); }); - }; - - // Error handler for the request - const errorHandler = (error): void => { - this._handleError(error); - }; - - // Create the request - this._request = sendRequest(options, responseHandler).on('error', errorHandler); - - // Node 0.4 and later won't accept empty data. Make sure it's needed. - if (data) { - this._request.write(data); - } - - this._request.end(); - - this.dispatchEvent(new Event('loadstart')); + }); } /** + * Sends a local file system request. * - * @param url + * @param url URL. + * @returns Promise. */ - private _sendLocalRequest(url: UrlObject): void { - if (this._settings.method !== 'GET') { - throw new DOMException( - 'XMLHttpRequest: Only GET method is supported', - DOMExceptionNameEnum.notSupportedError - ); - } + private _sendLocalRequest(url: UrlObject): Promise { + return new Promise((resolve) => { + if (!this._ownerDocument.defaultView.happyDOM.settings.enableFileSystemHttpRequests) { + throw new DOMException( + 'File system is disabled by default for security reasons. To enable it, set the "window.happyDOM.settings.enableFileSystemHttpRequests" option to true.', + DOMExceptionNameEnum.securityError + ); + } - if (this._settings.async) { - FS.readFile(decodeURI(url.pathname.slice(1)), 'utf8', (error: Error, data: Buffer) => { - if (error) { - this._handleError(error); - } else { + if (this._settings.method !== 'GET') { + throw new DOMException( + 'Only GET method is supported', + DOMExceptionNameEnum.notSupportedError + ); + } + + if (this._settings.async) { + FS.readFile(decodeURI(url.pathname.slice(1)), 'utf8', (error: Error, data: Buffer) => { + if (error) { + this._onError(error); + } else { + this.status = 200; + this.responseText = data.toString(); + this._setState(XMLHttpRequestReadyStateEnum.done); + } + resolve(); + }); + } else { + try { + this.responseText = FS.readFileSync(decodeURI(url.pathname.slice(1)), 'utf8'); this.status = 200; - this.responseText = data.toString(); this._setState(XMLHttpRequestReadyStateEnum.done); + } catch (error) { + this._onError(error); } - }); - } else { - try { - this.responseText = FS.readFileSync(decodeURI(url.pathname.slice(1)), 'utf8'); - this.status = 200; - this._setState(XMLHttpRequestReadyStateEnum.done); - } catch (error) { - this._handleError(error); + resolve(); } - } + }); + } + + /** + * Called when an error is encountered to deal with it. + * + * @param error Error object. + * @param status HTTP status code to use rather than the default (0) for XHR errors. + */ + private _onError(error: Error | string, status = 0): void { + this.status = status; + this.statusText = error.toString(); + this.responseText = error instanceof Error ? error.stack : ''; + this._errorFlag = true; + this._setState(XMLHttpRequestReadyStateEnum.done); } } From 861690af991044d9bf3f02daaffe7ffcc3140a4a Mon Sep 17 00:00:00 2001 From: David Ortner Date: Wed, 19 Oct 2022 01:10:41 +0200 Subject: [PATCH 27/40] #463@trivial: Removes unused dependency. --- packages/happy-dom/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/happy-dom/package.json b/packages/happy-dom/package.json index d69160d59..8c6c8e077 100644 --- a/packages/happy-dom/package.json +++ b/packages/happy-dom/package.json @@ -53,8 +53,7 @@ "whatwg-encoding": "^2.0.0", "whatwg-mimetype": "^3.0.0", "webidl-conversions": "^7.0.0", - "css.escape": "^1.5.1", - "xmlhttprequest-ssl": "^1.6.3" + "css.escape": "^1.5.1" }, "devDependencies": { "@types/he": "^1.1.2", From 6f8d5dd3809515406495cf8f660f558fb174efe2 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Fri, 21 Oct 2022 00:19:10 +0800 Subject: [PATCH 28/40] #520@minor: Fixes syntax error. --- packages/happy-dom/src/window/Window.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index 76a4314fd..10d1e4f3c 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -150,7 +150,8 @@ export default class Window extends EventTarget implements IWindow { }, settings: { disableJavaScriptEvaluation: false, - disableCSSEvaluation: false, + disableJavaScriptFileLoading: false, + disableCSSFileLoading: false, enableFileSystemHttpRequests: false } }; From 3a7f974ca67ee66d76e2453b7a50cdd1f6e66140 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Fri, 21 Oct 2022 15:50:10 +0800 Subject: [PATCH 29/40] #520@minor: Optimize the code. --- .../src/xml-http-request/XMLHttpRequest.ts | 39 +++++++------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 87b236cd5..f913e7430 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -123,7 +123,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Private properties private readonly _ownerDocument: IDocument = null; - private _request = null; + private _request: HTTP.ClientRequest = null; private _response = null; private _requestHeaders = {}; private _sendFlag = false; @@ -534,11 +534,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { */ private _sendSyncRequest(options: HTTPS.RequestOptions, ssl: boolean, data?: string): void { // Synchronous - // Create a temporary file for communication with the other Node process - const contentFile = '.node-xml-http-request-content-' + process.pid; - const syncFile = '.node-xml-http-request-sync-' + process.pid; - FS.writeFileSync(syncFile, '', 'utf8'); - + // Note: console.log === stdout // The async request the other Node process executes const execString = ` const HTTP = require('http'); @@ -555,14 +551,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { responseData = Buffer.concat([responseData, Buffer.from(chunk)]); }); response.on('end', () => { - FS.writeFileSync('${contentFile}', JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, text: responseText, data: responseData.toString('base64')}}), 'utf8'); - FS.unlinkSync('${syncFile}'); + console.log(JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, text: responseText, data: responseData.toString('base64')}})); }); response.on('error', (error) => { - FS.writeFileSync('${contentFile}', 'NODE-XML-HTTP-REQUEST-ERROR:' + JSON.stringify(error), 'utf8').on('error', (error) => { - FS.writeFileSync('${contentFile}', 'NODE-XML-HTTP-REQUEST-ERROR:' + JSON.stringify(error), 'utf8'); - FS.unlinkSync('${syncFile}'); - }); + console.log(JSON.stringify({err: error, data: null})); }); }); request.write(\`${JSON.stringify(data).slice(1, -1).replace(/'/g, "\\'")}\`); @@ -570,29 +562,27 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { `.trim(); // Start the other Node Process, executing this string - ChildProcess.execFileSync(process.argv[0], ['-e', execString]); + const content = ChildProcess.execFileSync(process.argv[0], ['-e', execString], { + encoding: 'utf-8' + }); - // If syncFile still exists, the request failed, if contentFile doesn't exist, the request failed. - if (FS.existsSync(syncFile) || !FS.existsSync(contentFile)) { + // If content is null string, then there was an error + if (!content) { throw new DOMException('Synchronous request failed', DOMExceptionNameEnum.networkError); } this.responseURL = RelativeURL.getAbsoluteURL( this._ownerDocument.defaultView.location, this._settings.url ).href; - const content = FS.readFileSync(contentFile, 'utf8'); - - // Remove the temporary file - FS.unlinkSync(contentFile); - if (content.match(/^NODE-XML-HTTP-REQUEST-ERROR:/)) { + const { err: error, data: responseObj } = JSON.parse(content); + if (error) { // If the file returned an error, handle it - const errorObj = JSON.parse(content.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, '')); + const errorObj = JSON.parse(content.replace(/^NODE-XML-HTTP-REQUEST-ERROR:/, '')); this._onError(errorObj, 503); - } else { - // If the file returned okay, parse its data and move to the DONE state - const { data: responseObj } = JSON.parse(content); + } + if (responseObj) { this._response = { statusCode: responseObj.statusCode, headers: responseObj.headers }; this.status = responseObj.statusCode; this.responseText = responseObj.text; @@ -848,6 +838,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._onError(error); } else { this.status = 200; + this.response = data; // TODO: ResponseType. this.responseText = data.toString(); this._setState(XMLHttpRequestReadyStateEnum.done); } From 941450c9625a9317396ce29a7c6472a54e995316 Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Fri, 21 Oct 2022 15:53:10 +0800 Subject: [PATCH 30/40] #520@minor: Optimize the code. --- packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index f913e7430..d1ba3bada 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -577,9 +577,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { const { err: error, data: responseObj } = JSON.parse(content); if (error) { - // If the file returned an error, handle it - const errorObj = JSON.parse(content.replace(/^NODE-XML-HTTP-REQUEST-ERROR:/, '')); - this._onError(errorObj, 503); + this._onError(error, 503); } if (responseObj) { From 1646f8e893831da0f8a941cc7fb2b181dac9956f Mon Sep 17 00:00:00 2001 From: Mas0nShi Date: Sat, 22 Oct 2022 13:57:37 +0800 Subject: [PATCH 31/40] #520@minor: Continue to improve XMLHttpRequest. --- .../src/xml-http-request/XMLHttpRequest.ts | 482 ++++++++++++------ 1 file changed, 314 insertions(+), 168 deletions(-) diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index d1ba3bada..962253e33 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -6,11 +6,14 @@ import XMLHttpRequestEventTarget from './XMLHttpRequestEventTarget'; import XMLHttpRequestReadyStateEnum from './XMLHttpRequestReadyStateEnum'; import Event from '../event/Event'; import IDocument from '../nodes/document/IDocument'; +import Blob from '../file/Blob'; import RelativeURL from '../location/RelativeURL'; import XMLHttpRequestUpload from './XMLHttpRequestUpload'; import DOMException from '../exception/DOMException'; import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum'; +import HTMLDocument from '../nodes/html-document/HTMLDocument'; import { UrlObject } from 'url'; +import { default as DOMParserImplementation } from '../dom-parser/DOMParser'; // SSL certificates generated for Happy DOM to be able to perform HTTPS requests: @@ -113,24 +116,41 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Public properties public readyState: XMLHttpRequestReadyStateEnum = XMLHttpRequestReadyStateEnum.unsent; - public responseText = ''; - public responseXML = ''; + // Public responseXML = ''; public responseURL = ''; - public response = null; + public response: ArrayBuffer | Blob | IDocument | object | string = null; public status: number = null; public statusText: string = null; public upload: XMLHttpRequestUpload = new XMLHttpRequestUpload(); // Private properties private readonly _ownerDocument: IDocument = null; - private _request: HTTP.ClientRequest = null; - private _response = null; - private _requestHeaders = {}; - private _sendFlag = false; - private _errorFlag = false; - private _abortedFlag = false; - private _responseType = ''; - private _asyncTaskID: number = null; + private flags: { + // Async response or sync response. + _response: HTTP.IncomingMessage | { headers: string[]; statusCode: number }; + // Response fields + _responseType: string; + _responseText: string; + _responseXML: IDocument; + _asyncRequest: HTTP.ClientRequest; + _asyncTaskID: number; + _requestHeaders: object; + send: boolean; + error: boolean; + aborted: boolean; + } = { + _response: null, + _responseType: '', + _responseText: '', + _responseXML: null, + _asyncRequest: null, + _asyncTaskID: null, + _requestHeaders: {}, + send: false, + error: false, + aborted: false + }; + private _settings: { method: string; url: string; @@ -153,26 +173,82 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._ownerDocument = XMLHttpRequest._ownerDocument; } + /** + * Set response text. + * + * @param value + */ + public set responseText(value: string) { + this.flags._responseText = value; + } + + /** + * 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 === 'text' || this.responseType === '') { + return this.flags._responseText; + } + throw new 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 + ); + } + + /** + * Sets the response XML. + * + * @param value The response XML. + */ + public set responseXML(value: IDocument) { + this.flags._responseXML = value; + } + + /** + * Get the responseXML. + * + * @throws {DOMException} If the response type is not text or empty. + * @returns Response XML. + */ + public get responseXML(): IDocument { + if (this.responseType === 'document' || this.responseType === '') { + return this.flags._responseXML; + } + throw new 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 + ); + } + /** * 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: string) { - if (this.readyState !== XMLHttpRequestReadyStateEnum.opened) { + // ResponseType can only be set when the state is unsent or opened. + if ( + this.readyState !== XMLHttpRequestReadyStateEnum.opened && + this.readyState !== XMLHttpRequestReadyStateEnum.unsent + ) { throw new DOMException( `Failed to set the 'responseType' property on 'XMLHttpRequest': The object's state must be OPENED.`, DOMExceptionNameEnum.invalidStateError ); } - if (this._settings.async === false) { + // Sync requests can only have empty string or 'text' as response type. + if (!this._settings.async) { throw new 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; + this.flags._responseType = type; } /** @@ -181,7 +257,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns Response type. */ public get responseType(): string { - return this._responseType; + return this.flags._responseType; } /** @@ -195,8 +271,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { */ public open(method: string, url: string, async = true, user?: string, password?: string): void { this.abort(); - this._errorFlag = false; - this._abortedFlag = false; + this.flags = { ...this.flags, aborted: false, error: false }; const upperMethod = method.toUpperCase(); @@ -242,10 +317,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if (FORBIDDEN_REQUEST_HEADERS.includes(lowerHeader)) { return false; } - if (this._sendFlag) { + if (this.flags.send) { throw new DOMException('send flag is true', DOMExceptionNameEnum.invalidStateError); } - this._requestHeaders[lowerHeader] = value; + this.flags._requestHeaders[lowerHeader] = value; return true; } @@ -261,10 +336,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if ( typeof header === 'string' && this.readyState > XMLHttpRequestReadyStateEnum.opened && - this._response.headers[lowerHeader] && - !this._errorFlag + this.flags._response.headers[lowerHeader] && + !this.flags.error ) { - return this._response.headers[lowerHeader]; + return this.flags._response.headers[lowerHeader]; } return null; @@ -276,15 +351,15 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns A string with all response headers separated by CR+LF. */ public getAllResponseHeaders(): string { - if (this.readyState < XMLHttpRequestReadyStateEnum.headersRecieved || this._errorFlag) { + if (this.readyState < XMLHttpRequestReadyStateEnum.headersRecieved || this.flags.error) { return ''; } let result = ''; - for (const name of Object.keys(this._response.headers)) { + for (const name of Object.keys(this.flags._response.headers)) { // Cookie headers are excluded if (name !== 'set-cookie' && name !== 'set-cookie2') { - result += `${name}: ${this._response.headers[name]}\r\n`; + result += `${name}: ${this.flags._response.headers[name]}\r\n`; } } @@ -299,8 +374,8 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { */ public getRequestHeader(name: string): string { const lowerName = name.toLowerCase(); - if (typeof name === 'string' && this._requestHeaders[lowerName]) { - return this._requestHeaders[lowerName]; + if (typeof name === 'string' && this.flags._requestHeaders[lowerName]) { + return this.flags._requestHeaders[lowerName]; } return ''; @@ -319,28 +394,29 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * Aborts a request. */ public abort(): void { - if (this._request) { - this._request.abort(); - this._request = null; + if (this.flags._asyncRequest) { + this.flags._asyncRequest.destroy(); + this.flags._asyncRequest = null; } - this._requestHeaders = {}; + this.flags._requestHeaders = {}; this.responseText = ''; - this.responseXML = ''; + this.responseXML = null; + + this.flags = { ...this.flags, aborted: true, error: true }; - this._errorFlag = this._abortedFlag = true; if ( this.readyState !== XMLHttpRequestReadyStateEnum.unsent && - (this.readyState !== XMLHttpRequestReadyStateEnum.opened || this._sendFlag) && + (this.readyState !== XMLHttpRequestReadyStateEnum.opened || this.flags.send) && this.readyState !== XMLHttpRequestReadyStateEnum.done ) { - this._sendFlag = false; + this.flags.send = false; this._setState(XMLHttpRequestReadyStateEnum.done); } this.readyState = XMLHttpRequestReadyStateEnum.unsent; - if (this._asyncTaskID !== null) { - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._asyncTaskID); + if (this.flags._asyncTaskID !== null) { + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this.flags._asyncTaskID); } } @@ -352,7 +428,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { private _setState(state: XMLHttpRequestReadyStateEnum): void { if ( this.readyState === state || - (this.readyState === XMLHttpRequestReadyStateEnum.unsent && this._abortedFlag) + (this.readyState === XMLHttpRequestReadyStateEnum.unsent && this.flags.aborted) ) { return; } @@ -370,9 +446,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if (this.readyState === XMLHttpRequestReadyStateEnum.done) { let fire: Event; - if (this._abortedFlag) { + if (this.flags.aborted) { fire = new Event('abort'); - } else if (this._errorFlag) { + } else if (this.flags.error) { fire = new Event('error'); } else { fire = new Event('load'); @@ -411,22 +487,31 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ); } - if (this._sendFlag) { + if (this.flags.send) { throw new DOMException( 'send has already been called', DOMExceptionNameEnum.invalidStateError ); } - const url = RelativeURL.getAbsoluteURL( - this._ownerDocument.defaultView.location, - this._settings.url - ); + const { location } = this._ownerDocument.defaultView; + + const url = RelativeURL.getAbsoluteURL(location, this._settings.url); + // Security check. + if (url.protocol === 'http:' && location.protocol === 'https:') { + throw new DOMException( + `Mixed Content: The page at '${location.href}' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint '${url.href}'. This request has been blocked; the content must be served over HTTPS.`, + DOMExceptionNameEnum.securityError + ); + } + + // TODO: CORS check. + let ssl = false; let local = false; let host; - this._asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask(); + this.flags._asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask(); // Determine the server switch (url.protocol) { @@ -455,7 +540,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Load files off the local filesystem (file://) if (local) { await this._sendLocalRequest(url); - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._asyncTaskID); + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this.flags._asyncTaskID); return; } @@ -466,33 +551,33 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { const uri = url.pathname + (url.search ? url.search : ''); // Set the Host header or the server may reject the request - this._requestHeaders['host'] = host; + this.flags._requestHeaders['host'] = host; if (!((ssl && port === 443) || port === 80)) { - this._requestHeaders['host'] += ':' + url.port; + this.flags._requestHeaders['host'] += ':' + url.port; } // Set Basic Auth if necessary if (this._settings.user) { this._settings.password ??= ''; const authBuffer = Buffer.from(this._settings.user + ':' + this._settings.password); - this._requestHeaders['authorization'] = 'Basic ' + authBuffer.toString('base64'); + this.flags._requestHeaders['authorization'] = 'Basic ' + authBuffer.toString('base64'); } - - // Set content length header + // Set the Content-Length header if method is POST if (this._settings.method === 'GET' || this._settings.method === 'HEAD') { data = null; - } else if (data) { - this._requestHeaders['content-length'] = Buffer.isBuffer(data) - ? data.length - : Buffer.byteLength(data); + } else if (this._settings.method === 'POST') { + // Set default content type if not set. + if (!this.flags._requestHeaders['content-type']) { + this.flags._requestHeaders['content-type'] = 'text/plain;charset=UTF-8'; + } - if (!this._requestHeaders['content-type']) { - this._requestHeaders['content-type'] = 'text/plain;charset=UTF-8'; + if (data) { + this.flags._requestHeaders['content-length'] = Buffer.isBuffer(data) + ? data.length + : Buffer.byteLength(data); + } else { + this.flags._requestHeaders['content-length'] = 0; } - } else if (this._settings.method === 'POST') { - // For a post with no data set Content-Length: 0. - // This is required by buggy servers that don't meet the specs. - this._requestHeaders['content-length'] = 0; } const options: HTTPS.RequestOptions = { @@ -500,20 +585,15 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { port: port, path: uri, method: this._settings.method, - headers: Object.assign(this._getDefaultRequestHeaders(), this._requestHeaders), + headers: { ...this._getDefaultRequestHeaders(), ...this.flags._requestHeaders }, agent: false, rejectUnauthorized: true, - key: null, - cert: null + key: ssl ? SSL_KEY : null, + cert: ssl ? SSL_CERT : null }; - if (ssl) { - options.key = SSL_KEY; - options.cert = SSL_CERT; - } - // Reset error flag - this._errorFlag = false; + this.flags.error = false; // Handle async requests if (this._settings.async) { @@ -522,7 +602,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._sendSyncRequest(options, ssl, data); } - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._asyncTaskID); + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this.flags._asyncTaskID); } /** @@ -557,51 +637,55 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { console.log(JSON.stringify({err: error, data: null})); }); }); - request.write(\`${JSON.stringify(data).slice(1, -1).replace(/'/g, "\\'")}\`); + request.write(\`${JSON.stringify(data ?? '') + .slice(1, -1) + .replace(/'/g, "\\'")}\`); request.end(); `.trim(); // Start the other Node Process, executing this string const content = ChildProcess.execFileSync(process.argv[0], ['-e', execString], { - encoding: 'utf-8' + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 1024 // TODO: Consistent buffer size: 1GB. }); - // If content is null string, then there was an error - if (!content) { + // If content length is 0, then there was an error + if (!content.length) { throw new DOMException('Synchronous request failed', DOMExceptionNameEnum.networkError); } - this.responseURL = RelativeURL.getAbsoluteURL( - this._ownerDocument.defaultView.location, - this._settings.url - ).href; - const { err: error, data: responseObj } = JSON.parse(content); + const { err: error, data: responseObj } = JSON.parse(content.toString()); if (error) { this._onError(error, 503); } if (responseObj) { - this._response = { statusCode: responseObj.statusCode, headers: responseObj.headers }; + this.flags._response = { statusCode: responseObj.statusCode, headers: responseObj.headers }; this.status = responseObj.statusCode; + // Sync responseType === '' + this.response = responseObj.text; this.responseText = responseObj.text; - // Sync response is always text. - this.response = Buffer.from(responseObj.data, 'base64').toString('utf8'); - + this.responseXML = null; + this.responseURL = RelativeURL.getAbsoluteURL( + this._ownerDocument.defaultView.location, + this._settings.url + ).href; // Set Cookies. - if (this._response.headers['set-cookie']) { + if (this.flags._response.headers['set-cookie']) { // TODO: Bugs in CookieJar. - this._ownerDocument.defaultView.document.cookie = this._response.headers['set-cookie']; + this._ownerDocument.defaultView.document.cookie = + this.flags._response.headers['set-cookie']; } // Redirect. if ( - this._response.statusCode === 301 || - this._response.statusCode === 302 || - this._response.statusCode === 303 || - this._response.statusCode === 307 + this.flags._response.statusCode === 301 || + this.flags._response.statusCode === 302 || + this.flags._response.statusCode === 303 || + this.flags._response.statusCode === 307 ) { const redirectUrl = RelativeURL.getAbsoluteURL( this._ownerDocument.defaultView.location, - this._response.headers['location'] + this.flags._response.headers['location'] ); ssl = redirectUrl.protocol === 'https:'; this._settings.url = redirectUrl.href; @@ -611,7 +695,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { host: redirectUrl.host, path: redirectUrl.pathname + (redirectUrl.search ?? ''), port: redirectUrl.port || (ssl ? 443 : 80), - method: this._response.statusCode === 303 ? 'GET' : this._settings.method, + method: this.flags._response.statusCode === 303 ? 'GET' : this._settings.method, headers: Object.assign(options.headers, { referer: redirectUrl.origin, host: redirectUrl.host @@ -643,26 +727,29 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { const sendRequest = ssl ? HTTPS.request : HTTP.request; // Request is being sent, set send flag - this._sendFlag = true; + this.flags.send = true; // As per spec, this is called here for historical reasons. this.dispatchEvent(new Event('readystatechange')); // Create the request - this._request = sendRequest(options, async (response: HTTP.IncomingMessage) => { - await this._onAsyncResponse(response, options, ssl, data); - resolve(); - }).on('error', (error: Error) => { + this.flags._asyncRequest = sendRequest( + options, + async (response: HTTP.IncomingMessage) => { + await this._onAsyncResponse(response, options, ssl, data); + resolve(); + } + ).on('error', (error: Error) => { this._onError(error); resolve(); }); // Node 0.4 and later won't accept empty data. Make sure it's needed. if (data) { - this._request.write(data); + this.flags._asyncRequest.write(data); } - this._request.end(); + this.flags._asyncRequest.end(); this.dispatchEvent(new Event('loadstart')); }); @@ -686,19 +773,19 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { return new Promise((resolve) => { // Set response var to the response we got back // This is so it remains accessable outside this scope - this._response = response; + this.flags._response = response; // Check for redirect // @TODO Prevent looped redirects if ( - this._response.statusCode === 301 || - this._response.statusCode === 302 || - this._response.statusCode === 303 || - this._response.statusCode === 307 + this.flags._response.statusCode === 301 || + this.flags._response.statusCode === 302 || + this.flags._response.statusCode === 303 || + this.flags._response.statusCode === 307 ) { // TODO: redirect url protocol change. // Change URL to the redirect location - this._settings.url = this._response.headers.location; + this._settings.url = this.flags._response.headers.location; // Parse the new URL. const redirectUrl = RelativeURL.getAbsoluteURL( this._ownerDocument.defaultView.location, @@ -708,16 +795,14 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ssl = redirectUrl.protocol === 'https:'; // Issue the new request this._sendAsyncRequest( - Object.assign(options, { + { + ...options, host: redirectUrl.hostname, port: redirectUrl.port, path: redirectUrl.pathname + (redirectUrl.search ?? ''), - method: this._response.statusCode === 303 ? 'GET' : this._settings.method, - headers: Object.assign(options.headers, { - referer: redirectUrl.origin, - host: redirectUrl.host - }) - }), + method: this.flags._response.statusCode === 303 ? 'GET' : this._settings.method, + headers: { ...options.headers, referer: redirectUrl.origin, host: redirectUrl.host } + }, ssl, data ); @@ -725,75 +810,42 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { return; } - if (this._response && this._response.setEncoding) { - this._response.setEncoding('utf8'); + if (this.flags._response && this.flags._response.setEncoding) { + this.flags._response.setEncoding('utf-8'); } this._setState(XMLHttpRequestReadyStateEnum.headersRecieved); - this.status = this._response.statusCode; + this.status = this.flags._response.statusCode; // Initialize response. - this.response = new Uint8Array(0); + let tempResponse = Buffer.from(new Uint8Array(0)); - this._response.on('data', (chunk) => { + this.flags._response.on('data', (chunk: Uint8Array) => { // Make sure there's some data if (chunk) { - this.response = Buffer.concat([this.response, Buffer.from(chunk)]); + tempResponse = Buffer.concat([tempResponse, Buffer.from(chunk)]); } // Don't emit state changes if the connection has been aborted. - if (this._sendFlag) { + if (this.flags.send) { this._setState(XMLHttpRequestReadyStateEnum.loading); } }); - this._response.on('end', () => { - if (this._sendFlag) { + this.flags._response.on('end', () => { + if (this.flags.send) { // The sendFlag needs to be set before setState is called. Otherwise, if we are chaining callbacks // There can be a timing issue (the callback is called and a new call is made before the flag is reset). - this._sendFlag = false; + this.flags.send = false; - // Redirect URL. + // Set response according to responseType. + const { response, responseXML, responseText } = this._responseTypeSelector(tempResponse); + this.response = response; + this.responseXML = responseXML; + this.responseText = responseText; this.responseURL = RelativeURL.getAbsoluteURL( this._ownerDocument.defaultView.location, this._settings.url ).href; - // Progress response. - this.responseText = this.response.toString(); - switch (this.responseType) { - case 'arraybuffer': - this.response = Buffer.from(this.response); - break; - case 'blob': - try { - this.response = new this._ownerDocument.defaultView.Blob([this.response]); - } catch (e) { - this.response = null; - } - break; - case 'document': - try { - this.response = new this._ownerDocument.defaultView.DOMParser().parseFromString( - this.response.toString(), - 'text/html' - ); - } catch (e) { - this.response = null; - } - break; - case 'json': - try { - this.response = JSON.parse(this.response.toString()); - } catch (e) { - this.response = null; - } - break; - case 'text': - case '': - default: - this.response = this.response.toString(); - break; - } - // Discard the 'end' event if the connection has been aborted this._setState(XMLHttpRequestReadyStateEnum.done); } @@ -801,7 +853,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { resolve(); }); - this._response.on('error', (error) => { + this.flags._response.on('error', (error) => { this._onError(error); resolve(); }); @@ -831,21 +883,41 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } if (this._settings.async) { - FS.readFile(decodeURI(url.pathname.slice(1)), 'utf8', (error: Error, data: Buffer) => { + // Async request. + FS.readFile(decodeURI(url.pathname.slice(1)), (error: Error, data: Buffer) => { if (error) { this._onError(error); } else { this.status = 200; - this.response = data; // TODO: ResponseType. - this.responseText = data.toString(); + + const { response, responseXML, responseText } = this._responseTypeSelector(data); + this.response = response; + this.responseXML = responseXML; + this.responseText = responseText; + this.responseURL = RelativeURL.getAbsoluteURL( + this._ownerDocument.defaultView.location, + this._settings.url + ).href; + this._setState(XMLHttpRequestReadyStateEnum.done); } resolve(); }); } else { + // Sync request. try { - this.responseText = FS.readFileSync(decodeURI(url.pathname.slice(1)), 'utf8'); + const tempResponse = FS.readFileSync(decodeURI(url.pathname.slice(1))); this.status = 200; + + const { response, responseXML, responseText } = this._responseTypeSelector(tempResponse); + this.response = response; + this.responseXML = responseXML; + this.responseText = responseText; + this.responseURL = RelativeURL.getAbsoluteURL( + this._ownerDocument.defaultView.location, + this._settings.url + ).href; + this._setState(XMLHttpRequestReadyStateEnum.done); } catch (error) { this._onError(error); @@ -855,6 +927,80 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { }); } + /** + * Set response according to responseType. + * + * @param data + */ + private _responseTypeSelector(data: Buffer): { + response: ArrayBuffer | Blob | IDocument | object | string; + responseText: string; + responseXML: IDocument; + } { + switch (this.responseType) { + case 'arraybuffer': + // See: https://github.com/jsdom/jsdom/blob/c3c421c364510e053478520500bccafd97f5fa39/lib/jsdom/living/helpers/binary-data.js + const newAB = new ArrayBuffer(data.length); + const view = new Uint8Array(newAB); + view.set(data); + return { + response: view, + responseText: null, + responseXML: null + }; + case 'blob': + try { + return { + response: new this._ownerDocument.defaultView.Blob([new Uint8Array(data)], { + type: this.getResponseHeader('content-type') || '' + }), + responseText: null, + responseXML: null + }; + } catch (e) { + return { response: null, responseText: null, responseXML: null }; + } + case 'document': + try { + // Create a new document. + const document = new HTMLDocument(); + document.defaultView.happyDOM.settings.enableFileSystemHttpRequests = false; + document.defaultView.happyDOM.settings.disableJavaScriptEvaluation = true; + document.defaultView.happyDOM.settings.disableCSSFileLoading = true; + document.defaultView.happyDOM.settings.disableJavaScriptFileLoading = true; + /* eslint-disable jsdoc/require-jsdoc */ + class DOMParser extends DOMParserImplementation { + public static _ownerDocument: IDocument = document; + } + const res = new DOMParser().parseFromString(data.toString(), 'text/xml'); + return { + response: res, + responseText: null, + responseXML: res + }; + } catch (e) { + return { response: null, responseText: null, responseXML: null }; + } + case 'json': + try { + return { + response: JSON.parse(data.toString()), + responseText: null, + responseXML: null + }; + } catch (e) { + return { response: null, responseText: null, responseXML: null }; + } + case 'text': + case '': + default: + return { + response: data.toString(), + responseText: data.toString(), + responseXML: null + }; + } + } /** * Called when an error is encountered to deal with it. * @@ -865,7 +1011,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this.status = status; this.statusText = error.toString(); this.responseText = error instanceof Error ? error.stack : ''; - this._errorFlag = true; + this.flags.error = true; this._setState(XMLHttpRequestReadyStateEnum.done); } } From 677a37694428d20965d5c034c797edf1affeaab4 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 27 Oct 2022 00:52:48 +0200 Subject: [PATCH 32/40] #463@trivial: Continue on XMLHttpRequest implementation. --- .../src/xml-http-request/XMLHttpRequest.ts | 434 ++++++++++-------- packages/happy-dom/test/setup.js | 206 +++++++-- .../xml-http-request/XMLHttpRequest.test.ts | 19 +- 3 files changed, 430 insertions(+), 229 deletions(-) diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 962253e33..1f64d06f1 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -13,7 +13,7 @@ import DOMException from '../exception/DOMException'; import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum'; import HTMLDocument from '../nodes/html-document/HTMLDocument'; import { UrlObject } from 'url'; -import { default as DOMParserImplementation } from '../dom-parser/DOMParser'; +import DOMParser, { default as DOMParserImplementation } from '../dom-parser/DOMParser'; // SSL certificates generated for Happy DOM to be able to perform HTTPS requests: @@ -125,27 +125,27 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Private properties private readonly _ownerDocument: IDocument = null; - private flags: { + private _flags: { // Async response or sync response. - _response: HTTP.IncomingMessage | { headers: string[]; statusCode: number }; + response: HTTP.IncomingMessage | { headers: string[]; statusCode: number }; // Response fields - _responseType: string; - _responseText: string; - _responseXML: IDocument; - _asyncRequest: HTTP.ClientRequest; - _asyncTaskID: number; - _requestHeaders: object; + responseType: string; + responseText: string; + responseXML: IDocument; + asyncRequest: HTTP.ClientRequest; + asyncTaskID: number; + requestHeaders: object; send: boolean; error: boolean; aborted: boolean; } = { - _response: null, - _responseType: '', - _responseText: '', - _responseXML: null, - _asyncRequest: null, - _asyncTaskID: null, - _requestHeaders: {}, + response: null, + responseType: '', + responseText: '', + responseXML: null, + asyncRequest: null, + asyncTaskID: null, + requestHeaders: {}, send: false, error: false, aborted: false @@ -179,7 +179,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param value */ public set responseText(value: string) { - this.flags._responseText = value; + this._flags.responseText = value; } /** @@ -190,7 +190,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { */ public get responseText(): string { if (this.responseType === 'text' || this.responseType === '') { - return this.flags._responseText; + return this._flags.responseText; } throw new 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}').`, @@ -204,7 +204,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param value The response XML. */ public set responseXML(value: IDocument) { - this.flags._responseXML = value; + this._flags.responseXML = value; } /** @@ -215,7 +215,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { */ public get responseXML(): IDocument { if (this.responseType === 'document' || this.responseType === '') { - return this.flags._responseXML; + return this._flags.responseXML; } throw new 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}').`, @@ -248,7 +248,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { DOMExceptionNameEnum.invalidStateError ); } - this.flags._responseType = type; + this._flags.responseType = type; } /** @@ -257,7 +257,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns Response type. */ public get responseType(): string { - return this.flags._responseType; + return this._flags.responseType; } /** @@ -271,7 +271,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { */ public open(method: string, url: string, async = true, user?: string, password?: string): void { this.abort(); - this.flags = { ...this.flags, aborted: false, error: false }; + this._flags = { ...this._flags, aborted: false, error: false }; const upperMethod = method.toUpperCase(); @@ -317,10 +317,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if (FORBIDDEN_REQUEST_HEADERS.includes(lowerHeader)) { return false; } - if (this.flags.send) { + if (this._flags.send) { throw new DOMException('send flag is true', DOMExceptionNameEnum.invalidStateError); } - this.flags._requestHeaders[lowerHeader] = value; + this._flags.requestHeaders[lowerHeader] = value; return true; } @@ -336,10 +336,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if ( typeof header === 'string' && this.readyState > XMLHttpRequestReadyStateEnum.opened && - this.flags._response.headers[lowerHeader] && - !this.flags.error + this._flags.response.headers[lowerHeader] && + !this._flags.error ) { - return this.flags._response.headers[lowerHeader]; + return this._flags.response.headers[lowerHeader]; } return null; @@ -351,15 +351,15 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns A string with all response headers separated by CR+LF. */ public getAllResponseHeaders(): string { - if (this.readyState < XMLHttpRequestReadyStateEnum.headersRecieved || this.flags.error) { + if (this.readyState < XMLHttpRequestReadyStateEnum.headersRecieved || this._flags.error) { return ''; } let result = ''; - for (const name of Object.keys(this.flags._response.headers)) { + for (const name of Object.keys(this._flags.response.headers)) { // Cookie headers are excluded if (name !== 'set-cookie' && name !== 'set-cookie2') { - result += `${name}: ${this.flags._response.headers[name]}\r\n`; + result += `${name}: ${this._flags.response.headers[name]}\r\n`; } } @@ -374,8 +374,8 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { */ public getRequestHeader(name: string): string { const lowerName = name.toLowerCase(); - if (typeof name === 'string' && this.flags._requestHeaders[lowerName]) { - return this.flags._requestHeaders[lowerName]; + if (typeof name === 'string' && this._flags.requestHeaders[lowerName]) { + return this._flags.requestHeaders[lowerName]; } return ''; @@ -394,29 +394,29 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * Aborts a request. */ public abort(): void { - if (this.flags._asyncRequest) { - this.flags._asyncRequest.destroy(); - this.flags._asyncRequest = null; + if (this._flags.asyncRequest) { + this._flags.asyncRequest.destroy(); + this._flags.asyncRequest = null; } - this.flags._requestHeaders = {}; + this._flags.requestHeaders = {}; this.responseText = ''; this.responseXML = null; - this.flags = { ...this.flags, aborted: true, error: true }; + this._flags = { ...this._flags, aborted: true, error: true }; if ( this.readyState !== XMLHttpRequestReadyStateEnum.unsent && - (this.readyState !== XMLHttpRequestReadyStateEnum.opened || this.flags.send) && + (this.readyState !== XMLHttpRequestReadyStateEnum.opened || this._flags.send) && this.readyState !== XMLHttpRequestReadyStateEnum.done ) { - this.flags.send = false; + this._flags.send = false; this._setState(XMLHttpRequestReadyStateEnum.done); } this.readyState = XMLHttpRequestReadyStateEnum.unsent; - if (this.flags._asyncTaskID !== null) { - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this.flags._asyncTaskID); + if (this._flags.asyncTaskID !== null) { + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._flags.asyncTaskID); } } @@ -428,7 +428,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { private _setState(state: XMLHttpRequestReadyStateEnum): void { if ( this.readyState === state || - (this.readyState === XMLHttpRequestReadyStateEnum.unsent && this.flags.aborted) + (this.readyState === XMLHttpRequestReadyStateEnum.unsent && this._flags.aborted) ) { return; } @@ -446,9 +446,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if (this.readyState === XMLHttpRequestReadyStateEnum.done) { let fire: Event; - if (this.flags.aborted) { + if (this._flags.aborted) { fire = new Event('abort'); - } else if (this.flags.error) { + } else if (this._flags.error) { fire = new Event('error'); } else { fire = new Event('load'); @@ -487,7 +487,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ); } - if (this.flags.send) { + if (this._flags.send) { throw new DOMException( 'send has already been called', DOMExceptionNameEnum.invalidStateError @@ -511,7 +511,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { let local = false; let host; - this.flags._asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask(); + this._flags.asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask(); // Determine the server switch (url.protocol) { @@ -539,8 +539,12 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Load files off the local filesystem (file://) if (local) { - await this._sendLocalRequest(url); - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this.flags._asyncTaskID); + if (this._settings.async) { + await this._sendLocalAsyncRequest(url); + } else { + this._sendLocalSyncRequest(url); + } + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._flags.asyncTaskID); return; } @@ -551,32 +555,32 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { const uri = url.pathname + (url.search ? url.search : ''); // Set the Host header or the server may reject the request - this.flags._requestHeaders['host'] = host; + this._flags.requestHeaders['host'] = host; if (!((ssl && port === 443) || port === 80)) { - this.flags._requestHeaders['host'] += ':' + url.port; + this._flags.requestHeaders['host'] += ':' + url.port; } // Set Basic Auth if necessary if (this._settings.user) { this._settings.password ??= ''; const authBuffer = Buffer.from(this._settings.user + ':' + this._settings.password); - this.flags._requestHeaders['authorization'] = 'Basic ' + authBuffer.toString('base64'); + this._flags.requestHeaders['authorization'] = 'Basic ' + authBuffer.toString('base64'); } // Set the Content-Length header if method is POST if (this._settings.method === 'GET' || this._settings.method === 'HEAD') { data = null; } else if (this._settings.method === 'POST') { // Set default content type if not set. - if (!this.flags._requestHeaders['content-type']) { - this.flags._requestHeaders['content-type'] = 'text/plain;charset=UTF-8'; + if (!this._flags.requestHeaders['content-type']) { + this._flags.requestHeaders['content-type'] = 'text/plain;charset=UTF-8'; } if (data) { - this.flags._requestHeaders['content-length'] = Buffer.isBuffer(data) + this._flags.requestHeaders['content-length'] = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data); } else { - this.flags._requestHeaders['content-length'] = 0; + this._flags.requestHeaders['content-length'] = 0; } } @@ -585,7 +589,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { port: port, path: uri, method: this._settings.method, - headers: { ...this._getDefaultRequestHeaders(), ...this.flags._requestHeaders }, + headers: { ...this._getDefaultRequestHeaders(), ...this._flags.requestHeaders }, agent: false, rejectUnauthorized: true, key: ssl ? SSL_KEY : null, @@ -593,7 +597,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { }; // Reset error flag - this.flags.error = false; + this._flags.error = false; // Handle async requests if (this._settings.async) { @@ -602,7 +606,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._sendSyncRequest(options, ssl, data); } - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this.flags._asyncTaskID); + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._flags.asyncTaskID); } /** @@ -613,38 +617,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param data */ private _sendSyncRequest(options: HTTPS.RequestOptions, ssl: boolean, data?: string): void { - // Synchronous - // Note: console.log === stdout - // The async request the other Node process executes - const execString = ` - const HTTP = require('http'); - const HTTPS = require('https'); - const FS = require('fs'); - const sendRequest = HTTP${ssl ? 'S' : ''}.request; - const options = ${JSON.stringify(options)}; - const request = sendRequest(options, (response) => { - let responseText = ''; - let responseData = Buffer.alloc(0); - response.setEncoding('utf8'); - response.on('data', (chunk) => { - responseText += chunk; - responseData = Buffer.concat([responseData, Buffer.from(chunk)]); - }); - response.on('end', () => { - console.log(JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, text: responseText, data: responseData.toString('base64')}})); - }); - response.on('error', (error) => { - console.log(JSON.stringify({err: error, data: null})); - }); - }); - request.write(\`${JSON.stringify(data ?? '') - .slice(1, -1) - .replace(/'/g, "\\'")}\`); - request.end(); - `.trim(); + const scriptString = this._getSyncRequestScriptString(options, ssl, data); // Start the other Node Process, executing this string - const content = ChildProcess.execFileSync(process.argv[0], ['-e', execString], { + const content = ChildProcess.execFileSync(process.argv[0], ['-e', scriptString], { encoding: 'buffer', maxBuffer: 1024 * 1024 * 1024 // TODO: Consistent buffer size: 1GB. }); @@ -660,7 +636,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } if (responseObj) { - this.flags._response = { statusCode: responseObj.statusCode, headers: responseObj.headers }; + this._flags.response = { statusCode: responseObj.statusCode, headers: responseObj.headers }; this.status = responseObj.statusCode; // Sync responseType === '' this.response = responseObj.text; @@ -671,21 +647,21 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._settings.url ).href; // Set Cookies. - if (this.flags._response.headers['set-cookie']) { + if (this._flags.response.headers['set-cookie']) { // TODO: Bugs in CookieJar. this._ownerDocument.defaultView.document.cookie = - this.flags._response.headers['set-cookie']; + this._flags.response.headers['set-cookie']; } // Redirect. if ( - this.flags._response.statusCode === 301 || - this.flags._response.statusCode === 302 || - this.flags._response.statusCode === 303 || - this.flags._response.statusCode === 307 + this._flags.response.statusCode === 301 || + this._flags.response.statusCode === 302 || + this._flags.response.statusCode === 303 || + this._flags.response.statusCode === 307 ) { const redirectUrl = RelativeURL.getAbsoluteURL( this._ownerDocument.defaultView.location, - this.flags._response.headers['location'] + this._flags.response.headers['location'] ); ssl = redirectUrl.protocol === 'https:'; this._settings.url = redirectUrl.href; @@ -695,7 +671,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { host: redirectUrl.host, path: redirectUrl.pathname + (redirectUrl.search ?? ''), port: redirectUrl.port || (ssl ? 443 : 80), - method: this.flags._response.statusCode === 303 ? 'GET' : this._settings.method, + method: this._flags.response.statusCode === 303 ? 'GET' : this._settings.method, headers: Object.assign(options.headers, { referer: redirectUrl.origin, host: redirectUrl.host @@ -710,6 +686,48 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } } + /** + * Sends a synchronous request. + * + * @param options Options. + * @param ssl SSL. + * @param data Data. + */ + private _getSyncRequestScriptString( + options: HTTPS.RequestOptions, + ssl: boolean, + data?: string + ): string { + // Synchronous + // Note: console.log === stdout + // The async request the other Node process executes + return ` + const HTTP = require('http'); + const HTTPS = require('https'); + const sendRequest = HTTP${ssl ? 'S' : ''}.request; + const options = ${JSON.stringify(options)}; + const request = sendRequest(options, (response) => { + let responseText = ''; + let responseData = Buffer.alloc(0); + response.setEncoding('utf8'); + response.on('data', (chunk) => { + responseText += chunk; + responseData = Buffer.concat([responseData, Buffer.from(chunk)]); + }); + response.on('end', () => { + console.log(JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, text: responseText, data: responseData.toString('base64')}})); + }); + response.on('error', (error) => { + console.log(JSON.stringify({err: error, data: null})); + }); + }); + request.write(\`${JSON.stringify(data ?? '') + .slice(1, -1) + .replace(/'/g, "\\'")}\`); + request.end(); + `; + } + /** * Sends an async request. * @@ -727,13 +745,13 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { const sendRequest = ssl ? HTTPS.request : HTTP.request; // Request is being sent, set send flag - this.flags.send = true; + this._flags.send = true; // As per spec, this is called here for historical reasons. this.dispatchEvent(new Event('readystatechange')); // Create the request - this.flags._asyncRequest = sendRequest( + this._flags.asyncRequest = sendRequest( options, async (response: HTTP.IncomingMessage) => { await this._onAsyncResponse(response, options, ssl, data); @@ -746,10 +764,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Node 0.4 and later won't accept empty data. Make sure it's needed. if (data) { - this.flags._asyncRequest.write(data); + this._flags.asyncRequest.write(data); } - this.flags._asyncRequest.end(); + this._flags.asyncRequest.end(); this.dispatchEvent(new Event('loadstart')); }); @@ -773,19 +791,19 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { return new Promise((resolve) => { // Set response var to the response we got back // This is so it remains accessable outside this scope - this.flags._response = response; + this._flags.response = response; // Check for redirect // @TODO Prevent looped redirects if ( - this.flags._response.statusCode === 301 || - this.flags._response.statusCode === 302 || - this.flags._response.statusCode === 303 || - this.flags._response.statusCode === 307 + this._flags.response.statusCode === 301 || + this._flags.response.statusCode === 302 || + this._flags.response.statusCode === 303 || + this._flags.response.statusCode === 307 ) { // TODO: redirect url protocol change. // Change URL to the redirect location - this._settings.url = this.flags._response.headers.location; + this._settings.url = this._flags.response.headers.location; // Parse the new URL. const redirectUrl = RelativeURL.getAbsoluteURL( this._ownerDocument.defaultView.location, @@ -800,7 +818,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { host: redirectUrl.hostname, port: redirectUrl.port, path: redirectUrl.pathname + (redirectUrl.search ?? ''), - method: this.flags._response.statusCode === 303 ? 'GET' : this._settings.method, + method: this._flags.response.statusCode === 303 ? 'GET' : this._settings.method, headers: { ...options.headers, referer: redirectUrl.origin, host: redirectUrl.host } }, ssl, @@ -810,35 +828,35 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { return; } - if (this.flags._response && this.flags._response.setEncoding) { - this.flags._response.setEncoding('utf-8'); + if (this._flags.response && this._flags.response.setEncoding) { + this._flags.response.setEncoding('utf-8'); } this._setState(XMLHttpRequestReadyStateEnum.headersRecieved); - this.status = this.flags._response.statusCode; + this.status = this._flags.response.statusCode; // Initialize response. let tempResponse = Buffer.from(new Uint8Array(0)); - this.flags._response.on('data', (chunk: Uint8Array) => { + this._flags.response.on('data', (chunk: Uint8Array) => { // Make sure there's some data if (chunk) { tempResponse = Buffer.concat([tempResponse, Buffer.from(chunk)]); } // Don't emit state changes if the connection has been aborted. - if (this.flags.send) { + if (this._flags.send) { this._setState(XMLHttpRequestReadyStateEnum.loading); } }); - this.flags._response.on('end', () => { - if (this.flags.send) { + this._flags.response.on('end', () => { + if (this._flags.send) { // The sendFlag needs to be set before setState is called. Otherwise, if we are chaining callbacks // There can be a timing issue (the callback is called and a new call is made before the flag is reset). - this.flags.send = false; + this._flags.send = false; // Set response according to responseType. - const { response, responseXML, responseText } = this._responseTypeSelector(tempResponse); + const { response, responseXML, responseText } = this._parseResponseData(tempResponse); this.response = response; this.responseXML = responseXML; this.responseText = responseText; @@ -853,7 +871,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { resolve(); }); - this.flags._response.on('error', (error) => { + this._flags.response.on('error', (error) => { this._onError(error); resolve(); }); @@ -861,78 +879,98 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** - * Sends a local file system request. + * Sends a local file system async request. * * @param url URL. * @returns Promise. */ - private _sendLocalRequest(url: UrlObject): Promise { - return new Promise((resolve) => { - if (!this._ownerDocument.defaultView.happyDOM.settings.enableFileSystemHttpRequests) { - throw new DOMException( - 'File system is disabled by default for security reasons. To enable it, set the "window.happyDOM.settings.enableFileSystemHttpRequests" option to true.', - DOMExceptionNameEnum.securityError - ); - } + private async _sendLocalAsyncRequest(url: UrlObject): Promise { + if (!this._ownerDocument.defaultView.happyDOM.settings.enableFileSystemHttpRequests) { + throw new DOMException( + 'File system is disabled by default for security reasons. To enable it, set the "window.happyDOM.settings.enableFileSystemHttpRequests" option to true.', + DOMExceptionNameEnum.securityError + ); + } - if (this._settings.method !== 'GET') { - throw new DOMException( - 'Only GET method is supported', - DOMExceptionNameEnum.notSupportedError - ); - } + if (this._settings.method !== 'GET') { + throw new DOMException( + 'Only GET method is supported', + DOMExceptionNameEnum.notSupportedError + ); + } - if (this._settings.async) { - // Async request. - FS.readFile(decodeURI(url.pathname.slice(1)), (error: Error, data: Buffer) => { - if (error) { - this._onError(error); - } else { - this.status = 200; - - const { response, responseXML, responseText } = this._responseTypeSelector(data); - this.response = response; - this.responseXML = responseXML; - this.responseText = responseText; - this.responseURL = RelativeURL.getAbsoluteURL( - this._ownerDocument.defaultView.location, - this._settings.url - ).href; - - this._setState(XMLHttpRequestReadyStateEnum.done); - } - resolve(); - }); - } else { - // Sync request. - try { - const tempResponse = FS.readFileSync(decodeURI(url.pathname.slice(1))); - this.status = 200; + let data: Buffer; - const { response, responseXML, responseText } = this._responseTypeSelector(tempResponse); - this.response = response; - this.responseXML = responseXML; - this.responseText = responseText; - this.responseURL = RelativeURL.getAbsoluteURL( - this._ownerDocument.defaultView.location, - this._settings.url - ).href; + try { + data = await FS.promises.readFile(decodeURI(url.pathname.slice(1))); + } catch (error) { + this._onError(error); + } - this._setState(XMLHttpRequestReadyStateEnum.done); - } catch (error) { - this._onError(error); - } - resolve(); - } - }); + if (data) { + this._parseLocalRequestData(data); + } } /** - * Set response according to responseType. + * Sends a local file system synchronous request. * - * @param data + * @param url URL. + */ + private _sendLocalSyncRequest(url: UrlObject): void { + if (!this._ownerDocument.defaultView.happyDOM.settings.enableFileSystemHttpRequests) { + throw new DOMException( + 'File system is disabled by default for security reasons. To enable it, set the "window.happyDOM.settings.enableFileSystemHttpRequests" option to true.', + DOMExceptionNameEnum.securityError + ); + } + + if (this._settings.method !== 'GET') { + throw new DOMException( + 'Only GET method is supported', + DOMExceptionNameEnum.notSupportedError + ); + } + + let data: Buffer; + try { + data = FS.readFileSync(decodeURI(url.pathname.slice(1))); + } catch (error) { + this._onError(error); + } + + if (data) { + this._parseLocalRequestData(data); + } + } + + /** + * Parses local request data. + * + * @param data Data. */ - private _responseTypeSelector(data: Buffer): { + private _parseLocalRequestData(data: Buffer): void { + this.status = 200; + + const { response, responseXML, responseText } = this._parseResponseData(data); + this.response = response; + this.responseXML = responseXML; + this.responseText = responseText; + this.responseURL = RelativeURL.getAbsoluteURL( + this._ownerDocument.defaultView.location, + this._settings.url + ).href; + + this._setState(XMLHttpRequestReadyStateEnum.done); + } + + /** + * Returns response based to the "responseType" property. + * + * @param data Data. + * @returns Parsed response. + */ + private _parseResponseData(data: Buffer): { response: ArrayBuffer | Blob | IDocument | object | string; responseText: string; responseXML: IDocument; @@ -961,26 +999,31 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { return { response: null, responseText: null, responseXML: null }; } case 'document': + const window = this._ownerDocument.defaultView; + const happyDOMSettings = window.happyDOM.settings; + let response: IDocument; + + // Temporary disables unsecure features. + window.happyDOM.settings = { + ...happyDOMSettings, + enableFileSystemHttpRequests: false, + disableJavaScriptEvaluation: true, + disableCSSFileLoading: true, + disableJavaScriptFileLoading: true + }; + + const domParser = new window.DOMParser(); + try { - // Create a new document. - const document = new HTMLDocument(); - document.defaultView.happyDOM.settings.enableFileSystemHttpRequests = false; - document.defaultView.happyDOM.settings.disableJavaScriptEvaluation = true; - document.defaultView.happyDOM.settings.disableCSSFileLoading = true; - document.defaultView.happyDOM.settings.disableJavaScriptFileLoading = true; - /* eslint-disable jsdoc/require-jsdoc */ - class DOMParser extends DOMParserImplementation { - public static _ownerDocument: IDocument = document; - } - const res = new DOMParser().parseFromString(data.toString(), 'text/xml'); - return { - response: res, - responseText: null, - responseXML: res - }; + response = domParser.parseFromString(data.toString(), 'text/xml'); } catch (e) { return { response: null, responseText: null, responseXML: null }; } + + // Restores unsecure features. + window.happyDOM.settings = happyDOMSettings; + + return { response, responseText: null, responseXML: response }; case 'json': try { return { @@ -1001,6 +1044,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { }; } } + /** * Called when an error is encountered to deal with it. * @@ -1011,7 +1055,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this.status = status; this.statusText = error.toString(); this.responseText = error instanceof Error ? error.stack : ''; - this.flags.error = true; + this._flags.error = true; this._setState(XMLHttpRequestReadyStateEnum.done); } } diff --git a/packages/happy-dom/test/setup.js b/packages/happy-dom/test/setup.js index bc51b5c89..b1fade229 100644 --- a/packages/happy-dom/test/setup.js +++ b/packages/happy-dom/test/setup.js @@ -1,55 +1,116 @@ +/* eslint-disable camelcase */ + global.mockedModules = { - 'sync-request': { - statusCode: null, - body: null, - options: null - }, 'node-fetch': { - url: null, - init: null, - error: null, - response: { - arrayBuffer: Symbol('arrayBuffer'), - blob: Symbol('blob'), - buffer: Symbol('buffer'), - json: Symbol('json'), - text: Symbol('text'), - textConverted: Symbol('textConverted') + parameters: { + url: null, + init: null + }, + returnValue: { + error: null, + response: { + arrayBuffer: null, + blob: null, + buffer: null, + json: null, + text: null, + textConverted: null + } + } + }, + fs: { + promises: { + readFile: { + parameters: { + path: null + }, + returnValue: { + data: null + } + } + }, + readFileSync: { + parameters: { + path: null + }, + returnValue: { + data: null + } + } + }, + child_process: { + execFileSync: { + parameters: { + command: null, + args: null, + options: null + }, + returnValue: { + data: null + } + } + }, + http: { + request: { + parameters: { + options: null + }, + internal: { + body: null + }, + returnValue: { + response: { + headers: null, + statusCode: null, + statusMessage: null, + body: null, + error: null + }, + request: { + error: null + } + } } } }; +const initialMockedModules = JSON.parse(JSON.stringify(global.mockedModules)); + +global.resetMockedModules = () => { + global.mockedModules = JSON.parse(JSON.stringify(initialMockedModules)); +}; + jest.mock('sync-request', () => (method, url) => { - global.mockedModules['sync-request'].options = { + global.mockedModules['sync-request'].parameters = { method, url }; return { - getBody: () => global.mockedModules['sync-request'].body, - isError: () => global.mockedModules['sync-request'].statusCode !== 200, - statusCode: global.mockedModules['sync-request'].statusCode + getBody: () => global.mockedModules['sync-request'].returnValue.body, + isError: () => global.mockedModules['sync-request'].returnValue.statusCode !== 200, + statusCode: global.mockedModules['sync-request'].returnValue.statusCode }; }); /* eslint-disable jsdoc/require-jsdoc */ class NodeFetchResponse { arrayBuffer() { - return Promise.resolve(global.mockedModules['node-fetch'].response.arrayBuffer); + return Promise.resolve(global.mockedModules['node-fetch'].returnValue.response.arrayBuffer); } blob() { - return Promise.resolve(global.mockedModules['node-fetch'].response.blob); + return Promise.resolve(global.mockedModules['node-fetch'].returnValue.response.blob); } buffer() { - return Promise.resolve(global.mockedModules['node-fetch'].response.buffer); + return Promise.resolve(global.mockedModules['node-fetch'].rreturnValue.esponse.buffer); } json() { - return Promise.resolve(global.mockedModules['node-fetch'].response.json); + return Promise.resolve(global.mockedModules['node-fetch'].returnValue.response.json); } text() { - return Promise.resolve(global.mockedModules['node-fetch'].response.text); + return Promise.resolve(global.mockedModules['node-fetch'].returnValue.response.text); } textConverted() { - return Promise.resolve(global.mockedModules['node-fetch'].response.textConverted); + return Promise.resolve(global.mockedModules['node-fetch'].returnValue.response.textConverted); } } @@ -59,10 +120,10 @@ class NodeFetchHeaders {} jest.mock('node-fetch', () => { return Object.assign( (url, options) => { - global.mockedModules['node-fetch'].url = url; - global.mockedModules['node-fetch'].init = options; + global.mockedModules['node-fetch'].parameters.url = url; + global.mockedModules['node-fetch'].parameters.init = options; if (global.mockedModules['node-fetch'].error) { - return Promise.reject(global.mockedModules['node-fetch'].error); + return Promise.reject(global.mockedModules['node-fetch'].returnValue.error); } return Promise.resolve(new NodeFetchResponse()); }, @@ -73,3 +134,92 @@ jest.mock('node-fetch', () => { } ); }); + +jest.mock('fs', { + promises: { + readFile: (path) => { + global.mockedModules.fs.promises.readFile.parameters.path = path; + return Promise.resolve(global.mockedModules.fs.promises.readFile.returnValue.data); + } + }, + readFileSync: (path) => { + global.mockedModules.fs.readFileSync.parameters.path = path; + return global.mockedModules.fs.readFileSync.returnValue.data; + } +}); + +jest.mock('child_process', { + execFileSync: (command, args, options) => { + global.mockedModules.child_process.parameters = { + command, + args, + options + }; + return global.mockedModules.child_process.returnValue.data; + } +}); + +class IncomingMessage { + constructor() { + this.headers = {}; + this.statusCode = null; + this.statusMessage = null; + this._eventListeners = { + data: [], + end: [], + error: [] + }; + } + + on(event, callback) { + this._eventListeners[event].push(callback); + } +} + +const httpMock = { + request: (options, callback) => { + let errorCallback = null; + global.mockedModules.http.request.parameters = { + options + }; + return { + write: (chunk) => (global.mockedModules.http.request.internal.body += chunk), + end: () => { + if (global.mockedModules.http.request.returnValue.request.error) { + if (errorCallback) { + errorCallback(global.mockedModules.http.request.returnValue.request.error); + } + } else { + const response = new IncomingMessage(); + + response.headers = global.mockedModules.http.request.returnValue.response.headers; + response.statusCode = global.mockedModules.http.request.returnValue.response.statusCode; + response.statusMessage = + global.mockedModules.http.request.returnValue.response.statusMessage; + callback(response); + + if (global.mockedModules.http.request.returnValue.error) { + for (const listener of response._eventListeners.error) { + listener(global.mockedModules.http.request.returnValue.error); + } + } else { + for (const listener of response._eventListeners.data) { + listener(global.mockedModules.http.request.returnValue.response.body); + } + for (const listener of response._eventListeners.end) { + listener(); + } + } + } + }, + on: (event, callback) => { + if (event === 'error') { + errorCallback = callback; + } + } + }; + } +}; + +jest.mock('http', httpMock); +jest.mock('https', httpMock); diff --git a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts index 253222398..09822d8a7 100644 --- a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts +++ b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts @@ -1,16 +1,23 @@ import XMLHttpRequest from '../../src/xml-http-request/XMLHttpRequest'; import Window from '../../src/window/Window'; +import IWindow from '../../src/window/IWindow'; describe('XMLHttpRequest', () => { - let window: Window; - // @ts-ignore - let xhr: XMLHttpRequest; + let window: IWindow; + let xmlHttpRequest: XMLHttpRequest; + beforeEach(() => { window = new Window(); - xhr = new window.XMLHttpRequest(); + xmlHttpRequest = new window.XMLHttpRequest(); + }); + + afterEach(() => { + global['resetMockedModules'](); }); - it('XMLHttpRequest()', () => { - // TODO: Implement + describe('get responseText()', () => { + it('Returns response text for a sync request.', () => { + // TODO: Implement. + }); }); }); From 9b0b6ea7017b16294c9fda499c92d2edf44b9804 Mon Sep 17 00:00:00 2001 From: David Ortner Date: Mon, 14 Nov 2022 17:59:08 +0100 Subject: [PATCH 33/40] #463@trivial: Continue on XMLHttpRequest implementation. --- .../async-task-manager/AsyncTaskManager.ts | 60 +++-- .../src/xml-http-request/XMLHttpRequest.ts | 248 ++++++++---------- .../utilities/XMLHttpRequestURLUtility.ts | 64 +++++ .../test/fetch/ResourceFetchHandler.test.ts | 58 ++-- packages/happy-dom/test/setup.js | 194 +++++++------- packages/happy-dom/test/types.d.ts | 83 ++++++ packages/happy-dom/test/window/Window.test.ts | 48 ++-- .../xml-http-request/XMLHttpRequest.test.ts | 56 +++- 8 files changed, 523 insertions(+), 288 deletions(-) create mode 100644 packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestURLUtility.ts create mode 100644 packages/happy-dom/test/types.d.ts diff --git a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts index 2c2917a78..a853a2ea4 100644 --- a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts +++ b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts @@ -3,7 +3,7 @@ */ export default class AsyncTaskManager { private static taskID = 0; - private runningTasks: number[] = []; + private runningTasks: { [k: string]: () => void } = {}; private runningTimers: NodeJS.Timeout[] = []; private queue: { resolve: () => void; reject: (error: Error) => void }[] = []; @@ -24,21 +24,31 @@ export default class AsyncTaskManager { } /** - * Cancels all tasks. + * Ends all tasks. * * @param [error] Error. */ public cancelAll(error?: Error): void { - for (const timerID of this.runningTimers) { - global.clearTimeout(timerID); - } - + const runningTimers = this.runningTimers; + const runningTasks = this.runningTasks; const promises = this.queue; - this.runningTasks = []; + this.runningTasks = {}; this.runningTimers = []; this.queue = []; + for (const timer of runningTimers) { + global.clearTimeout(timer); + } + + try { + for (const key of Object.keys(runningTasks)) { + runningTasks[key](); + } + } catch (e) { + error = e; + } + for (const promise of promises) { if (error) { promise.reject(error); @@ -67,7 +77,7 @@ export default class AsyncTaskManager { if (index !== -1) { this.runningTimers.splice(index, 1); } - if (!this.runningTasks.length && !this.runningTimers.length) { + if (!Object.keys(this.runningTasks).length && !this.runningTimers.length) { this.cancelAll(); } } @@ -75,11 +85,12 @@ export default class AsyncTaskManager { /** * Starts an async task. * + * @param abortHandler Abort handler. * @returns Task ID. */ - public startTask(): number { + public startTask(abortHandler?: () => void): number { const taskID = this.newTaskID(); - this.runningTasks.push(taskID); + this.runningTasks[taskID] = abortHandler ? abortHandler : () => {}; return taskID; } @@ -89,12 +100,18 @@ export default class AsyncTaskManager { * @param taskID Task ID. */ public endTask(taskID: number): void { - const index = this.runningTasks.indexOf(taskID); - if (index !== -1) { - this.runningTasks.splice(index, 1); - } - if (!this.runningTasks.length && !this.runningTimers.length) { - this.cancelAll(); + if (this.runningTasks[taskID]) { + try { + this.runningTasks[taskID](); + } catch (error) { + this.cancelAll(error); + return; + } + delete this.runningTasks[taskID]; + + if (!Object.keys(this.runningTasks).length && !this.runningTimers.length) { + this.cancelAll(); + } } } @@ -104,16 +121,7 @@ export default class AsyncTaskManager { * @returns Count. */ public getTaskCount(): number { - return this.runningTasks.length; - } - - /** - * Returns the amount of running timers. - * - * @returns Count. - */ - public getTimerCount(): number { - return this.runningTimers.length; + return Object.keys(this.runningTasks).length; } /** diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 1f64d06f1..6acf597f6 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -11,9 +11,9 @@ import RelativeURL from '../location/RelativeURL'; import XMLHttpRequestUpload from './XMLHttpRequestUpload'; import DOMException from '../exception/DOMException'; import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum'; -import HTMLDocument from '../nodes/html-document/HTMLDocument'; import { UrlObject } from 'url'; -import DOMParser, { default as DOMParserImplementation } from '../dom-parser/DOMParser'; +import XMLHttpRequestURLUtility from './utilities/XMLHttpRequestURLUtility'; +import ProgressEvent from '../event/events/ProgressEvent'; // SSL certificates generated for Happy DOM to be able to perform HTTPS requests: @@ -387,99 +387,6 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param data Optional data to send as request body. */ public send(data?: string): void { - this._sendRequest(data); - } - - /** - * Aborts a request. - */ - public abort(): void { - if (this._flags.asyncRequest) { - this._flags.asyncRequest.destroy(); - this._flags.asyncRequest = null; - } - - this._flags.requestHeaders = {}; - this.responseText = ''; - this.responseXML = null; - - this._flags = { ...this._flags, aborted: true, error: true }; - - if ( - this.readyState !== XMLHttpRequestReadyStateEnum.unsent && - (this.readyState !== XMLHttpRequestReadyStateEnum.opened || this._flags.send) && - this.readyState !== XMLHttpRequestReadyStateEnum.done - ) { - this._flags.send = false; - this._setState(XMLHttpRequestReadyStateEnum.done); - } - this.readyState = XMLHttpRequestReadyStateEnum.unsent; - - if (this._flags.asyncTaskID !== null) { - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._flags.asyncTaskID); - } - } - - /** - * Changes readyState and calls onreadystatechange. - * - * @param state - */ - private _setState(state: XMLHttpRequestReadyStateEnum): void { - if ( - this.readyState === state || - (this.readyState === XMLHttpRequestReadyStateEnum.unsent && this._flags.aborted) - ) { - return; - } - - this.readyState = state; - - if ( - this._settings.async || - this.readyState < XMLHttpRequestReadyStateEnum.opened || - this.readyState === XMLHttpRequestReadyStateEnum.done - ) { - this.dispatchEvent(new Event('readystatechange')); - } - - if (this.readyState === XMLHttpRequestReadyStateEnum.done) { - let fire: Event; - - if (this._flags.aborted) { - fire = new Event('abort'); - } else if (this._flags.error) { - fire = new Event('error'); - } else { - fire = new Event('load'); - } - - this.dispatchEvent(fire); - this.dispatchEvent(new Event('loadend')); - } - } - - /** - * Default request headers. - * - * @returns Default request headers. - */ - private _getDefaultRequestHeaders(): { [key: string]: string } { - return { - accept: '*/*', - referer: this._ownerDocument.defaultView.location.origin, - 'user-agent': this._ownerDocument.defaultView.navigator.userAgent, - cookie: this._ownerDocument.defaultView.document.cookie - }; - } - - /** - * Sends a request. - * - * @param [data] Optional data to send as request body. - * @returns Promise that resolves when the request is done. - */ - private async _sendRequest(data?: string): Promise { if (this.readyState != XMLHttpRequestReadyStateEnum.opened) { throw new DOMException( 'connection must be opened before send() is called', @@ -497,6 +404,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { const { location } = this._ownerDocument.defaultView; const url = RelativeURL.getAbsoluteURL(location, this._settings.url); + // Security check. if (url.protocol === 'http:' && location.protocol === 'https:') { throw new DOMException( @@ -505,49 +413,21 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ); } - // TODO: CORS check. - - let ssl = false; - let local = false; - let host; - - this._flags.asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask(); - - // Determine the server - switch (url.protocol) { - case 'https:': - host = url.hostname; - ssl = true; - break; - - case 'http:': - host = url.hostname; - break; - - case 'file:': - local = true; - break; - - case undefined: - case '': - host = 'localhost'; - break; - - default: - throw new DOMException('Protocol not supported.', DOMExceptionNameEnum.notSupportedError); - } - // Load files off the local filesystem (file://) - if (local) { + if (XMLHttpRequestURLUtility.isLocal(url)) { if (this._settings.async) { - await this._sendLocalAsyncRequest(url); + this._sendLocalAsyncRequest(url); } else { this._sendLocalSyncRequest(url); } - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._flags.asyncTaskID); return; } + // TODO: CORS check. + + const host = XMLHttpRequestURLUtility.getHost(url); + const ssl = XMLHttpRequestURLUtility.isSSL(url); + // Default to port 80. If accessing localhost on another port be sure // To use http://localhost:port/path const port = Number(url.port) || (ssl ? 443 : 80); @@ -601,12 +481,93 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Handle async requests if (this._settings.async) { - await this._sendAsyncRequest(options, ssl, data); + this._sendAsyncRequest(options, ssl, data); } else { this._sendSyncRequest(options, ssl, data); } + } - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._flags.asyncTaskID); + /** + * Aborts a request. + */ + public abort(): void { + if (this._flags.asyncRequest) { + this._flags.asyncRequest.destroy(); + this._flags.asyncRequest = null; + } + + this._flags.requestHeaders = {}; + this.responseText = ''; + this.responseXML = null; + + this._flags = { ...this._flags, aborted: true, error: true }; + + if ( + this.readyState !== XMLHttpRequestReadyStateEnum.unsent && + (this.readyState !== XMLHttpRequestReadyStateEnum.opened || this._flags.send) && + this.readyState !== XMLHttpRequestReadyStateEnum.done + ) { + this._flags.send = false; + this._setState(XMLHttpRequestReadyStateEnum.done); + } + this.readyState = XMLHttpRequestReadyStateEnum.unsent; + + if (this._flags.asyncTaskID !== null) { + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._flags.asyncTaskID); + } + } + + /** + * Changes readyState and calls onreadystatechange. + * + * @param state + */ + private _setState(state: XMLHttpRequestReadyStateEnum): void { + if ( + this.readyState === state || + (this.readyState === XMLHttpRequestReadyStateEnum.unsent && this._flags.aborted) + ) { + return; + } + + this.readyState = state; + + if ( + this._settings.async || + this.readyState < XMLHttpRequestReadyStateEnum.opened || + this.readyState === XMLHttpRequestReadyStateEnum.done + ) { + this.dispatchEvent(new Event('readystatechange')); + } + + if (this.readyState === XMLHttpRequestReadyStateEnum.done) { + let fire: Event; + + if (this._flags.aborted) { + fire = new Event('abort'); + } else if (this._flags.error) { + fire = new Event('error'); + } else { + fire = new Event('load'); + } + + this.dispatchEvent(fire); + this.dispatchEvent(new Event('loadend')); + } + } + + /** + * Default request headers. + * + * @returns Default request headers. + */ + private _getDefaultRequestHeaders(): { [key: string]: string } { + return { + accept: '*/*', + referer: this._ownerDocument.defaultView.location.origin, + 'user-agent': this._ownerDocument.defaultView.navigator.userAgent, + cookie: this._ownerDocument.defaultView.document.cookie + }; } /** @@ -741,6 +702,11 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { data?: string ): Promise { return new Promise((resolve) => { + // Starts async task in Happy DOM + this._flags.asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask( + this.abort.bind(this) + ); + // Use the proper protocol const sendRequest = ssl ? HTTPS.request : HTTP.request; @@ -770,6 +736,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._flags.asyncRequest.end(); this.dispatchEvent(new Event('loadstart')); + + // Ends async task in Happy DOM + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._flags.asyncTaskID); }); } @@ -847,6 +816,15 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if (this._flags.send) { this._setState(XMLHttpRequestReadyStateEnum.loading); } + + const contentLength = Number(this._flags.response.headers['content-length']); + this.dispatchEvent( + new ProgressEvent('progress', { + lengthComputable: isNaN(contentLength) ? false : true, + loaded: tempResponse.length, + total: isNaN(contentLength) ? 0 : contentLength + }) + ); }); this._flags.response.on('end', () => { @@ -885,6 +863,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns Promise. */ private async _sendLocalAsyncRequest(url: UrlObject): Promise { + this._flags.asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask( + this.abort.bind(this) + ); + if (!this._ownerDocument.defaultView.happyDOM.settings.enableFileSystemHttpRequests) { throw new DOMException( 'File system is disabled by default for security reasons. To enable it, set the "window.happyDOM.settings.enableFileSystemHttpRequests" option to true.', @@ -910,6 +892,8 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if (data) { this._parseLocalRequestData(data); } + + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._flags.asyncTaskID); } /** diff --git a/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestURLUtility.ts b/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestURLUtility.ts new file mode 100644 index 000000000..551283d35 --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestURLUtility.ts @@ -0,0 +1,64 @@ +import { URL } from 'url'; + +/** + * URL utility. + */ +export default class XMLHttpRequestURLUtility { + /** + * Returns "true" if SSL. + * + * @param url URL. + * @returns "true" if SSL. + */ + public static isSSL(url: URL): boolean { + return url.protocol === 'https:'; + } + + /** + * Returns "true" if SSL. + * + * @param url URL. + * @returns "true" if SSL. + */ + public static isLocal(url: URL): boolean { + return url.protocol === 'file:'; + } + + /** + * Returns "true" if protocol is valid. + * + * @param url URL. + * @returns "true" if valid. + */ + public static isSupportedProtocol(url: URL): boolean { + switch (url.protocol) { + case 'https:': + case 'http:': + case 'file:': + case undefined: + case '': + return true; + } + + return false; + } + + /** + * Returns host. + * + * @param url URL. + * @returns Host. + */ + public static getHost(url: URL): string { + switch (url.protocol) { + case 'http:': + case 'https:': + return url.hostname; + case undefined: + case '': + return 'localhost'; + default: + return null; + } + } +} diff --git a/packages/happy-dom/test/fetch/ResourceFetchHandler.test.ts b/packages/happy-dom/test/fetch/ResourceFetchHandler.test.ts index 6fac713ea..c3aff218d 100644 --- a/packages/happy-dom/test/fetch/ResourceFetchHandler.test.ts +++ b/packages/happy-dom/test/fetch/ResourceFetchHandler.test.ts @@ -4,17 +4,14 @@ import IDocument from '../../src/nodes/document/IDocument'; import ResourceFetchHandler from '../../src/fetch/ResourceFetchHandler'; import IResponse from '../../src/fetch/IResponse'; -const MOCKED_SYNC_REQUEST = global['mockedModules']['sync-request']; +const URL = 'https://localhost:8080/base/'; describe('ResourceFetchHandler', () => { let window: IWindow; let document: IDocument; beforeEach(() => { - MOCKED_SYNC_REQUEST.options = null; - MOCKED_SYNC_REQUEST.statusCode = 200; - MOCKED_SYNC_REQUEST.body = 'test'; - window = new Window(); + window = new Window({ url: URL }); document = window.document; }); @@ -43,27 +40,52 @@ describe('ResourceFetchHandler', () => { describe('fetchSync()', () => { it('Returns resource data synchrounously.', () => { - window.location.href = 'https://localhost:8080/base/'; - const test = ResourceFetchHandler.fetchSync(document, 'path/to/script/'); - expect(MOCKED_SYNC_REQUEST.options).toEqual({ - method: 'GET', - url: 'https://localhost:8080/base/path/to/script/' + expect(mockedModules.modules.child_process.execFileSync.parameters.command).toEqual( + process.argv[0] + ); + expect(mockedModules.modules.child_process.execFileSync.parameters.args[0]).toBe('-e'); + expect( + mockedModules.modules.child_process.execFileSync.parameters.args[1].replace(/\s/gm, '') + ).toBe( + ` + const HTTP = require('http'); + const HTTPS = require('https'); + const sendRequest = HTTPS.request; + const options = {\"host\":\"localhost\",\"port\":8080,\"path\":\"/base/path/to/script/\",\"method\":\"GET\",\"headers\":{\"accept\":\"*/*\",\"referer\":\"https://localhost:8080\",\"user-agent\":\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:97.0) Gecko/20100101 Firefox/97.0\",\"cookie\":\"\",\"host\":\"localhost:8080\"},\"agent\":false,\"rejectUnauthorized\":true,\"key\":\"-----BEGIN PRIVATE KEY-----\\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCp6tBJASD9LFXF\\nGLCUi2K2Gvl7kQxmjWeyq3/E7FqBualbcgSa3SNulsfuIQXgMMSjsoA7BQI7BVZ4\\nXFGhqDyUbZrHjzCuCOUKdrjlcOfRd6DUBFqMvTiCzjodnRWvMiWhUZ+WfTOC39W6\\nbkE2qz05zl9CgGag/dwCfd2fhlVw/QAq2G1zI+WrYO41E3Q9XQPZG0xKIDL2XUqj\\no3nH6wB4KUPI4v6xMMueBkFV5nI4q30h3XlLxm1e0DpUyAeecXeMKGfulqDvuux3\\n/LmIA0BU1lipGX4SDvFoNyy9uAfjpQE6sFwHf1C1OHky2OjF6nl7dkMUb6RDUdQT\\n6LPk7mfTAgMBAAECggEAKkwTkTjAt4UjzK56tl+EMQTB+ep/hb/JgoaChci4Nva6\\nm9LkJpDJ0yuhlTuPNOGu8XjrxsVWas7HWarRf0Zb3i7yip6wZYI9Ub+AA015x4DZ\\n/i0fRU2NFbK0cM67qSL4jxG8gj+kZP3HPGNZxHwX/53JxMolwgmvjMc8NgvAlSFd\\nNnV9h4xtbhUh1NGS5zmP3iU2rwnE8JrIEzwy6axLom7nekAgkdcbAr0UoBs8gcgH\\naYNhU4Gz3tGcZZ0IXAfT/bJIH1Ko8AGv4pssWc3BXcmmNdm/+kzvHIxEIV7Qegmo\\nXG1ZyZCyD/0b4/3e8ySDBEDqwR+HeyTW2isWG2agAQKBgQDp44aTwr3dkIXY30xv\\nFPfUOipg/B49dWnffYJ9MWc1FT9ijNPAngWSk0EIiEQIazICcUBI4Yji6/KeyqLJ\\nGdLpDi1CkKqtyh73mjELinYp3EUQgEa77aQogGa2+nMOVfu+O5CtloUrv/A18jX3\\n+VEyaEASK0fWmnSI0OdlxQHIAQKBgQC5+xOls2F3MlKASvWRLlnW1wHqlDTtVoYg\\n5Nh8syZH4Ci2UH8tON3A5/7SWNM0t1cgV6Cw4zW8Z2spgIT/W0iYYrQ4hHL1xdCu\\n+CxL1km4Gy8Uwpsd+KdFahFqF/XTmLzW0HXLxWSK0fTwmdV0SFrKF3MXfTCU2AeZ\\njJoMFb6P0wKBgQC3Odw6s0vkYAzLGhuZxfZkVvDOK5RRF0NKpttr0iEFL9EJFkPo\\n2KKK8jr3QTDy229BBJGUxsJi6u6VwS8HlehpVQbV59kd7oKV/EBBx0XMg1fDlopT\\nPNbmN7i/zbIG4AsoOyebJZjL7kBzMn1e9vzKHWtcEHXlw/hZGja8vjooAQKBgAeg\\nxK2HLfg1mCyq5meN/yFQsENu0LzrT5UJzddPgcJw7zqLEqxIKNBAs7Ls8by3yFsL\\nPQwERa/0jfCl1M6kb9XQNpQa2pw6ANUsWKTDpUJn2wZ+9N3F1RaDwzMWyH5lRVmK\\nM0qoTfdjpSg5Jwgd75taWt4bxGJWeflSSv8z5R0BAoGAWL8c527AbeBvx2tOYKkD\\n2TFranvANNcoMrbeviZSkkGvMNDP3p8b6juJwXOIeWNr8q4vFgCzLmq6d1/9gYm2\\n3XJwwyD0LKlqzkBrrKU47qrnmMosUrIRlrAzd3HbShOptxc6Iz2apSaUDKGKXkaw\\ngl5OpEjeliU7Mus0BVS858g=\\n-----END PRIVATE KEY-----\",\"cert\":\"-----BEGIN CERTIFICATE-----\\nMIIDYzCCAkugAwIBAgIUJRKB/H66hpet1VfUlm0CiXqePA4wDQYJKoZIhvcNAQEL\\nBQAwQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVNYWxt\\nbzESMBAGA1UECgwJSGFwcHkgRE9NMB4XDTIyMTAxMTIyMDM0OVoXDTMyMTAwODIy\\nMDM0OVowQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVN\\nYWxtbzESMBAGA1UECgwJSGFwcHkgRE9NMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A\\nMIIBCgKCAQEAqerQSQEg/SxVxRiwlItithr5e5EMZo1nsqt/xOxagbmpW3IEmt0j\\nbpbH7iEF4DDEo7KAOwUCOwVWeFxRoag8lG2ax48wrgjlCna45XDn0Xeg1ARajL04\\ngs46HZ0VrzIloVGfln0zgt/Vum5BNqs9Oc5fQoBmoP3cAn3dn4ZVcP0AKthtcyPl\\nq2DuNRN0PV0D2RtMSiAy9l1Ko6N5x+sAeClDyOL+sTDLngZBVeZyOKt9Id15S8Zt\\nXtA6VMgHnnF3jChn7pag77rsd/y5iANAVNZYqRl+Eg7xaDcsvbgH46UBOrBcB39Q\\ntTh5Mtjoxep5e3ZDFG+kQ1HUE+iz5O5n0wIDAQABo1MwUTAdBgNVHQ4EFgQU69s9\\nYSobG/m2SN4L/7zTaF7iDbwwHwYDVR0jBBgwFoAU69s9YSobG/m2SN4L/7zTaF7i\\nDbwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAi/WUXx2oal8L\\nYnPlIuKfh49n/K18wXSYG//oFYwxfVxqpYH8hUiXVm/GUcXCxS++hUkaKLqXmH9q\\nMKJiCrZr3vS+2nsBKopkICu/TLdROl0sAI9lByfnEbfSAzjxe1IWJdK8NdY0y5m5\\n9pEr/URVIAp/CxrneyASb4q0Jg5To3FR7vYc+2X6wZn0MundKMg6Dp9/A37jiF3l\\nTt/EJp299YZcsUzh+LnRuggRjnoOVu1aLcLFlaUiwZfy9m8mLG6B/mdW/qNzNMh9\\nOqvg1zfGdpz/4D/2UUUBn6pq1vbsoAaF3OesoA3mfDcegDf/H9woJlpT0Wql+e68\\nY3FblSokcA==\\n-----END CERTIFICATE-----\"}; + const request = sendRequest(options, (response) => { + let responseText = ''; + let responseData = Buffer.alloc(0); + response.setEncoding('utf8'); + response.on('data', (chunk) => { + responseText += chunk; + responseData = Buffer.concat([responseData, Buffer.from(chunk)]); + }); + response.on('end', () => { + console.log(JSON.stringify({err: null, data: { statusCode: response.statusCode, headers: response.headers, text: responseText, data: responseData.toString('base64')}})); + }); + response.on('error', (error) => { + console.log(JSON.stringify({err: error, data: null})); + }); + }); + request.write(\`\`); + request.end(); + `.replace(/\s/gm, '') + ); + expect(mockedModules.modules.child_process.execFileSync.parameters.options).toEqual({ + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 1024 }); - expect(test).toBe('test'); + + expect(test).toBe('child_process.execFileSync.returnValue.data.text'); }); it('Handles error when resource is fetched synchrounously.', () => { - window.location.href = 'https://localhost:8080/base/'; - - MOCKED_SYNC_REQUEST.statusCode = 404; - + mockedModules.modules.child_process.execFileSync.returnValue.data.statusCode = 404; expect(() => { ResourceFetchHandler.fetchSync(document, 'path/to/script/'); - }).toThrowError( - 'Failed to perform request to "https://localhost:8080/base/path/to/script/". Status code: 404' - ); + }).toThrowError(`Failed to perform request to "${URL}path/to/script/". Status code: 404`); }); }); }); diff --git a/packages/happy-dom/test/setup.js b/packages/happy-dom/test/setup.js index b1fade229..d28afb368 100644 --- a/packages/happy-dom/test/setup.js +++ b/packages/happy-dom/test/setup.js @@ -1,6 +1,6 @@ /* eslint-disable camelcase */ -global.mockedModules = { +const modules = { 'node-fetch': { parameters: { url: null, @@ -9,12 +9,12 @@ global.mockedModules = { returnValue: { error: null, response: { - arrayBuffer: null, - blob: null, - buffer: null, - json: null, - text: null, - textConverted: null + arrayBuffer: 'nodeFetch.arrayBuffer', + blob: 'nodeFetch.blob', + buffer: 'nodeFetch.buffer', + json: 'nodeFetch.json', + text: 'nodeFetch.text', + textConverted: 'nodeFetch.textConverted' } } }, @@ -25,7 +25,7 @@ global.mockedModules = { path: null }, returnValue: { - data: null + data: 'fs.promises.readFile' } } }, @@ -34,7 +34,7 @@ global.mockedModules = { path: null }, returnValue: { - data: null + data: 'fs.readFileSync' } } }, @@ -46,7 +46,13 @@ global.mockedModules = { options: null }, returnValue: { - data: null + data: { + statusCode: 200, + headers: { key1: 'value1', key2: 'value2' }, + text: 'child_process.execFileSync.returnValue.data.text', + data: Buffer.from('child_process.execFileSync.returnValue.data.text').toString('base64') + }, + error: null } } }, @@ -60,10 +66,10 @@ global.mockedModules = { }, returnValue: { response: { - headers: null, - statusCode: null, - statusMessage: null, - body: null, + headers: 'http.request.headers', + statusCode: 'http.request.statusCode', + statusMessage: 'http.request.statusMessage', + body: 'http.request.body', error: null }, request: { @@ -74,43 +80,36 @@ global.mockedModules = { } }; -const initialMockedModules = JSON.parse(JSON.stringify(global.mockedModules)); - -global.resetMockedModules = () => { - global.mockedModules = JSON.parse(JSON.stringify(initialMockedModules)); +global.mockedModules = { + modules: JSON.parse(JSON.stringify(modules)), + reset: () => { + global.mockedModules.modules = JSON.parse(JSON.stringify(modules)); + } }; -jest.mock('sync-request', () => (method, url) => { - global.mockedModules['sync-request'].parameters = { - method, - url - }; - return { - getBody: () => global.mockedModules['sync-request'].returnValue.body, - isError: () => global.mockedModules['sync-request'].returnValue.statusCode !== 200, - statusCode: global.mockedModules['sync-request'].returnValue.statusCode - }; -}); - /* eslint-disable jsdoc/require-jsdoc */ class NodeFetchResponse { arrayBuffer() { - return Promise.resolve(global.mockedModules['node-fetch'].returnValue.response.arrayBuffer); + return Promise.resolve( + global.mockedModules.modules['node-fetch'].returnValue.response.arrayBuffer + ); } blob() { - return Promise.resolve(global.mockedModules['node-fetch'].returnValue.response.blob); + return Promise.resolve(global.mockedModules.modules['node-fetch'].returnValue.response.blob); } buffer() { - return Promise.resolve(global.mockedModules['node-fetch'].rreturnValue.esponse.buffer); + return Promise.resolve(global.mockedModules.modules['node-fetch'].returnValue.response.buffer); } json() { - return Promise.resolve(global.mockedModules['node-fetch'].returnValue.response.json); + return Promise.resolve(global.mockedModules.modules['node-fetch'].returnValue.response.json); } text() { - return Promise.resolve(global.mockedModules['node-fetch'].returnValue.response.text); + return Promise.resolve(global.mockedModules.modules['node-fetch'].returnValue.response.text); } textConverted() { - return Promise.resolve(global.mockedModules['node-fetch'].returnValue.response.textConverted); + return Promise.resolve( + global.mockedModules.modules['node-fetch'].returnValue.response.textConverted + ); } } @@ -120,10 +119,10 @@ class NodeFetchHeaders {} jest.mock('node-fetch', () => { return Object.assign( (url, options) => { - global.mockedModules['node-fetch'].parameters.url = url; - global.mockedModules['node-fetch'].parameters.init = options; - if (global.mockedModules['node-fetch'].error) { - return Promise.reject(global.mockedModules['node-fetch'].returnValue.error); + global.mockedModules.modules['node-fetch'].parameters.url = url; + global.mockedModules.modules['node-fetch'].parameters.init = options; + if (global.mockedModules.modules['node-fetch'].error) { + return Promise.reject(global.mockedModules.modules['node-fetch'].returnValue.error); } return Promise.resolve(new NodeFetchResponse()); }, @@ -135,29 +134,29 @@ jest.mock('node-fetch', () => { ); }); -jest.mock('fs', { +jest.mock('fs', () => ({ promises: { readFile: (path) => { - global.mockedModules.fs.promises.readFile.parameters.path = path; - return Promise.resolve(global.mockedModules.fs.promises.readFile.returnValue.data); + global.mockedModules.modules.fs.promises.readFile.parameters.path = path; + return Promise.resolve(global.mockedModules.modules.fs.promises.readFile.returnValue.data); } }, readFileSync: (path) => { - global.mockedModules.fs.readFileSync.parameters.path = path; - return global.mockedModules.fs.readFileSync.returnValue.data; + global.mockedModules.modules.fs.readFileSync.parameters.path = path; + return global.mockedModules.modules.fs.readFileSync.returnValue.data; } -}); +})); -jest.mock('child_process', { +jest.mock('child_process', () => ({ execFileSync: (command, args, options) => { - global.mockedModules.child_process.parameters = { + global.mockedModules.modules.child_process.execFileSync.parameters = { command, args, options }; - return global.mockedModules.child_process.returnValue.data; + return JSON.stringify(global.mockedModules.modules.child_process.execFileSync.returnValue); } -}); +})); class IncomingMessage { constructor() { @@ -176,49 +175,68 @@ class IncomingMessage { } } -const httpMock = { - request: (options, callback) => { - let errorCallback = null; - global.mockedModules.http.request.parameters = { - options - }; - return { - write: (chunk) => (global.mockedModules.http.request.internal.body += chunk), - end: () => { - if (global.mockedModules.http.request.returnValue.request.error) { - if (errorCallback) { - errorCallback(global.mockedModules.http.request.returnValue.request.error); - } - } else { - const response = new IncomingMessage(); +const httpMock = () => { + return { + request: (options, callback) => { + let errorCallback = null; + global.mockedModules.modules.http.request.parameters = { + options + }; + const request = { + write: (chunk) => (global.mockedModules.modules.http.request.internal.body += chunk), + end: () => { + setTimeout(() => { + if (global.mockedModules.modules.http.request.returnValue.request.error) { + if (errorCallback) { + errorCallback(global.mockedModules.modules.http.request.returnValue.request.error); + } + } else { + const response = new IncomingMessage(); - response.headers = global.mockedModules.http.request.returnValue.response.headers; - response.statusCode = global.mockedModules.http.request.returnValue.response.statusCode; - response.statusMessage = - global.mockedModules.http.request.returnValue.response.statusMessage; - callback(response); + response.headers = + global.mockedModules.modules.http.request.returnValue.response.headers; + response.statusCode = + global.mockedModules.modules.http.request.returnValue.response.statusCode; + response.statusMessage = + global.mockedModules.modules.http.request.returnValue.response.statusMessage; - if (global.mockedModules.http.request.returnValue.error) { - for (const listener of response._eventListeners.error) { - listener(global.mockedModules.http.request.returnValue.error); - } - } else { - for (const listener of response._eventListeners.data) { - listener(global.mockedModules.http.request.returnValue.response.body); - } - for (const listener of response._eventListeners.end) { - listener(); + callback(response); + + if (global.mockedModules.modules.http.request.returnValue.error) { + for (const listener of response._eventListeners.error) { + listener(global.mockedModules.modules.http.request.returnValue.error); + } + } else { + for (const listener of response._eventListeners.data) { + listener(global.mockedModules.modules.http.request.returnValue.response.body); + } + for (const listener of response._eventListeners.end) { + listener(); + } + } } + }); + }, + on: (event, callback) => { + if (event === 'error') { + errorCallback = callback; } + return request; } - }, - on: (event, callback) => { - if (event === 'error') { - errorCallback = callback; - } - } - }; - } + }; + return request; + }, + STATUS_CODES: { + 200: 'OK', + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable' + } + }; }; jest.mock('http', httpMock); diff --git a/packages/happy-dom/test/types.d.ts b/packages/happy-dom/test/types.d.ts new file mode 100644 index 000000000..a0794f4c0 --- /dev/null +++ b/packages/happy-dom/test/types.d.ts @@ -0,0 +1,83 @@ +declare let mockedModules: { + modules: { + 'node-fetch': { + parameters: { + url: string; + init: { + headers: { [k: string]: string }; + }; + }; + returnValue: { + error: Error; + response: { + arrayBuffer: Buffer; + blob: object; + buffer: Buffer; + json: object; + text: string; + textConverted: string; + }; + }; + }; + fs: { + promises: { + readFile: { + parameters: { + path: string; + }; + returnValue: { + data: Buffer; + }; + }; + }; + readFileSync: { + parameters: { + path: string; + }; + returnValue: { + data: Buffer; + }; + }; + }; + child_process: { + execFileSync: { + parameters: { + command: string; + args: string[]; + options: { encoding: string; maxBuffer: Buffer }; + }; + returnValue: { + data: { + statusCode: number; + headers: { [k: string]: string }; + text: string; + data: string; + }; + }; + }; + }; + http: { + request: { + parameters: { + options: object; + }; + internal: { + body: string; + }; + returnValue: { + response: { + headers: { [k: string]: string }; + statusCode: number; + statusMessage: string; + body: string; + error: Error; + }; + request: { + error: Error; + }; + }; + }; + }; + }; + reset: () => void; +}; diff --git a/packages/happy-dom/test/window/Window.test.ts b/packages/happy-dom/test/window/Window.test.ts index ceb53013f..3528fb5b9 100644 --- a/packages/happy-dom/test/window/Window.test.ts +++ b/packages/happy-dom/test/window/Window.test.ts @@ -13,22 +13,18 @@ import DOMException from '../../src/exception/DOMException'; import DOMExceptionNameEnum from '../../src/exception/DOMExceptionNameEnum'; import CustomElement from '../../test/CustomElement'; -const MOCKED_NODE_FETCH = global['mockedModules']['node-fetch']; - describe('Window', () => { let window: IWindow; let document: IDocument; beforeEach(() => { - MOCKED_NODE_FETCH.url = null; - MOCKED_NODE_FETCH.init = null; - MOCKED_NODE_FETCH.error = null; window = new Window(); document = window.document; window.customElements.define('custom-element', CustomElement); }); afterEach(() => { + mockedModules.reset(); jest.restoreAllMocks(); }); @@ -154,7 +150,7 @@ describe('Window', () => { it(`Handles the "${method}" method with the async task manager.`, async () => { const response = new window.Response(); const result = await response[method](); - expect(result).toBe(MOCKED_NODE_FETCH.response[method]); + expect(result).toBe(mockedModules.modules['node-fetch'].returnValue.response[method]); }); } }); @@ -169,7 +165,7 @@ describe('Window', () => { it(`Handles the "${method}" method with the async task manager.`, async () => { const request = new window.Request('test'); const result = await request[method](); - expect(result).toBe(MOCKED_NODE_FETCH.response[method]); + expect(result).toBe(mockedModules.modules['node-fetch'].returnValue.response[method]); }); } }); @@ -466,13 +462,19 @@ describe('Window', () => { const response = await window.fetch(expectedUrl, expectedOptions); const result = await response[method](); - expect(MOCKED_NODE_FETCH.url).toBe(expectedUrl); + expect(mockedModules.modules['node-fetch'].parameters.url).toBe(expectedUrl); - expect(MOCKED_NODE_FETCH.init.headers['user-agent']).toBe(window.navigator.userAgent); - expect(MOCKED_NODE_FETCH.init.headers['cookie']).toBe(window.document.cookie); - expect(MOCKED_NODE_FETCH.init.headers['referer']).toBe(window.location.origin); + expect(mockedModules.modules['node-fetch'].parameters.init.headers['user-agent']).toBe( + window.navigator.userAgent + ); + expect(mockedModules.modules['node-fetch'].parameters.init.headers['cookie']).toBe( + window.document.cookie + ); + expect(mockedModules.modules['node-fetch'].parameters.init.headers['referer']).toBe( + window.location.origin + ); - expect(result).toEqual(MOCKED_NODE_FETCH.response[method]); + expect(result).toEqual(mockedModules.modules['node-fetch'].returnValue.response[method]); }); } @@ -485,22 +487,30 @@ describe('Window', () => { const response = await window.fetch(expectedPath, expectedOptions); const textResponse = await response.text(); - expect(MOCKED_NODE_FETCH.url).toBe('https://localhost:8080' + expectedPath); + expect(mockedModules.modules['node-fetch'].parameters.url).toBe( + 'https://localhost:8080' + expectedPath + ); - expect(MOCKED_NODE_FETCH.init.headers['user-agent']).toBe(window.navigator.userAgent); - expect(MOCKED_NODE_FETCH.init.headers['cookie']).toBe(window.document.cookie); - expect(MOCKED_NODE_FETCH.init.headers['referer']).toBe(window.location.origin); + expect(mockedModules.modules['node-fetch'].parameters.init.headers['user-agent']).toBe( + window.navigator.userAgent + ); + expect(mockedModules.modules['node-fetch'].parameters.init.headers['cookie']).toBe( + window.document.cookie + ); + expect(mockedModules.modules['node-fetch'].parameters.init.headers['referer']).toBe( + window.location.origin + ); - expect(textResponse).toEqual(MOCKED_NODE_FETCH.response.text); + expect(textResponse).toEqual(mockedModules.modules['node-fetch'].returnValue.response.text); }); it('Handles error JSON request.', async () => { - MOCKED_NODE_FETCH.error = new Error('error'); + mockedModules.modules['node-fetch'].returnValue.error = new Error('error'); window.location.href = 'https://localhost:8080'; try { await window.fetch('/url/', {}); } catch (error) { - expect(error).toBe(MOCKED_NODE_FETCH.error); + expect(error).toBe(mockedModules.modules['node-fetch'].returnValue.error); } }); }); diff --git a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts index 09822d8a7..a1af7558a 100644 --- a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts +++ b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts @@ -1,23 +1,69 @@ import XMLHttpRequest from '../../src/xml-http-request/XMLHttpRequest'; import Window from '../../src/window/Window'; import IWindow from '../../src/window/IWindow'; +import XMLHttpRequestReadyStateEnum from '../../src/xml-http-request/XMLHttpRequestReadyStateEnum'; + +const URL = 'https://localhost:8080'; describe('XMLHttpRequest', () => { let window: IWindow; - let xmlHttpRequest: XMLHttpRequest; + let request: XMLHttpRequest; beforeEach(() => { window = new Window(); - xmlHttpRequest = new window.XMLHttpRequest(); + request = new window.XMLHttpRequest(); }); afterEach(() => { - global['resetMockedModules'](); + mockedModules.reset(); + }); + + describe('get readyState()', () => { + it('Returns ready state for synchrounous requests.', () => { + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.unsent); + + request.open('GET', URL, false); + request.send(); + + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + }); + + it('Returns ready state for asynchrounous requests.', (done) => { + let isProgressTriggered = false; + + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.unsent); + + request.open('GET', URL, true); + + request.addEventListener('progress', () => { + isProgressTriggered = true; + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); + }); + + request.addEventListener('load', () => { + expect(isProgressTriggered).toBe(true); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + done(); + }); + + request.send(); + }); }); describe('get responseText()', () => { - it('Returns response text for a sync request.', () => { - // TODO: Implement. + it('Returns response text for synchrounous requests.', () => { + request.open('GET', URL, false); + request.send(); + expect(request.responseText).toBe('child_process.execFileSync.returnValue.data.text'); + }); + + it('Returns response text for asynchrounous requests.', (done) => { + request.open('GET', URL, true); + request.addEventListener('load', () => { + expect(request.responseText).toBe('http.request.body'); + done(); + }); + request.send(); }); }); }); From e7bb2bc3888401b2ab2fbaaa9aa01afa3d556947 Mon Sep 17 00:00:00 2001 From: David Ortner Date: Sat, 3 Dec 2022 17:25:44 +0100 Subject: [PATCH 34/40] #463@trivial: Continues on XMLHttpRequest implementation. --- .../src/xml-http-request/XMLHttpRequest.ts | 401 ++++++++---------- .../XMLHttpRequestCertificate.ts | 52 +++ .../XMLHttpResponseTypeEnum.ts | 9 + .../XMLHttpRequestSyncRequestScriptBuilder.ts | 44 ++ .../test/fetch/ResourceFetchHandler.test.ts | 49 +-- packages/happy-dom/test/setup.js | 3 +- .../xml-http-request/XMLHttpRequest.test.ts | 200 ++++++++- 7 files changed, 496 insertions(+), 262 deletions(-) create mode 100644 packages/happy-dom/src/xml-http-request/XMLHttpRequestCertificate.ts create mode 100644 packages/happy-dom/src/xml-http-request/XMLHttpResponseTypeEnum.ts create mode 100644 packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder.ts diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 6acf597f6..7d233c0b5 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -14,59 +14,9 @@ import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum'; import { UrlObject } from 'url'; import XMLHttpRequestURLUtility from './utilities/XMLHttpRequestURLUtility'; import ProgressEvent from '../event/events/ProgressEvent'; - -// SSL certificates generated for Happy DOM to be able to perform HTTPS requests: - -const SSL_CERT = `-----BEGIN CERTIFICATE----- -MIIDYzCCAkugAwIBAgIUJRKB/H66hpet1VfUlm0CiXqePA4wDQYJKoZIhvcNAQEL -BQAwQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVNYWxt -bzESMBAGA1UECgwJSGFwcHkgRE9NMB4XDTIyMTAxMTIyMDM0OVoXDTMyMTAwODIy -MDM0OVowQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVN -YWxtbzESMBAGA1UECgwJSGFwcHkgRE9NMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEAqerQSQEg/SxVxRiwlItithr5e5EMZo1nsqt/xOxagbmpW3IEmt0j -bpbH7iEF4DDEo7KAOwUCOwVWeFxRoag8lG2ax48wrgjlCna45XDn0Xeg1ARajL04 -gs46HZ0VrzIloVGfln0zgt/Vum5BNqs9Oc5fQoBmoP3cAn3dn4ZVcP0AKthtcyPl -q2DuNRN0PV0D2RtMSiAy9l1Ko6N5x+sAeClDyOL+sTDLngZBVeZyOKt9Id15S8Zt -XtA6VMgHnnF3jChn7pag77rsd/y5iANAVNZYqRl+Eg7xaDcsvbgH46UBOrBcB39Q -tTh5Mtjoxep5e3ZDFG+kQ1HUE+iz5O5n0wIDAQABo1MwUTAdBgNVHQ4EFgQU69s9 -YSobG/m2SN4L/7zTaF7iDbwwHwYDVR0jBBgwFoAU69s9YSobG/m2SN4L/7zTaF7i -DbwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAi/WUXx2oal8L -YnPlIuKfh49n/K18wXSYG//oFYwxfVxqpYH8hUiXVm/GUcXCxS++hUkaKLqXmH9q -MKJiCrZr3vS+2nsBKopkICu/TLdROl0sAI9lByfnEbfSAzjxe1IWJdK8NdY0y5m5 -9pEr/URVIAp/CxrneyASb4q0Jg5To3FR7vYc+2X6wZn0MundKMg6Dp9/A37jiF3l -Tt/EJp299YZcsUzh+LnRuggRjnoOVu1aLcLFlaUiwZfy9m8mLG6B/mdW/qNzNMh9 -Oqvg1zfGdpz/4D/2UUUBn6pq1vbsoAaF3OesoA3mfDcegDf/H9woJlpT0Wql+e68 -Y3FblSokcA== ------END CERTIFICATE-----`; - -const SSL_KEY = `-----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCp6tBJASD9LFXF -GLCUi2K2Gvl7kQxmjWeyq3/E7FqBualbcgSa3SNulsfuIQXgMMSjsoA7BQI7BVZ4 -XFGhqDyUbZrHjzCuCOUKdrjlcOfRd6DUBFqMvTiCzjodnRWvMiWhUZ+WfTOC39W6 -bkE2qz05zl9CgGag/dwCfd2fhlVw/QAq2G1zI+WrYO41E3Q9XQPZG0xKIDL2XUqj -o3nH6wB4KUPI4v6xMMueBkFV5nI4q30h3XlLxm1e0DpUyAeecXeMKGfulqDvuux3 -/LmIA0BU1lipGX4SDvFoNyy9uAfjpQE6sFwHf1C1OHky2OjF6nl7dkMUb6RDUdQT -6LPk7mfTAgMBAAECggEAKkwTkTjAt4UjzK56tl+EMQTB+ep/hb/JgoaChci4Nva6 -m9LkJpDJ0yuhlTuPNOGu8XjrxsVWas7HWarRf0Zb3i7yip6wZYI9Ub+AA015x4DZ -/i0fRU2NFbK0cM67qSL4jxG8gj+kZP3HPGNZxHwX/53JxMolwgmvjMc8NgvAlSFd -NnV9h4xtbhUh1NGS5zmP3iU2rwnE8JrIEzwy6axLom7nekAgkdcbAr0UoBs8gcgH -aYNhU4Gz3tGcZZ0IXAfT/bJIH1Ko8AGv4pssWc3BXcmmNdm/+kzvHIxEIV7Qegmo -XG1ZyZCyD/0b4/3e8ySDBEDqwR+HeyTW2isWG2agAQKBgQDp44aTwr3dkIXY30xv -FPfUOipg/B49dWnffYJ9MWc1FT9ijNPAngWSk0EIiEQIazICcUBI4Yji6/KeyqLJ -GdLpDi1CkKqtyh73mjELinYp3EUQgEa77aQogGa2+nMOVfu+O5CtloUrv/A18jX3 -+VEyaEASK0fWmnSI0OdlxQHIAQKBgQC5+xOls2F3MlKASvWRLlnW1wHqlDTtVoYg -5Nh8syZH4Ci2UH8tON3A5/7SWNM0t1cgV6Cw4zW8Z2spgIT/W0iYYrQ4hHL1xdCu -+CxL1km4Gy8Uwpsd+KdFahFqF/XTmLzW0HXLxWSK0fTwmdV0SFrKF3MXfTCU2AeZ -jJoMFb6P0wKBgQC3Odw6s0vkYAzLGhuZxfZkVvDOK5RRF0NKpttr0iEFL9EJFkPo -2KKK8jr3QTDy229BBJGUxsJi6u6VwS8HlehpVQbV59kd7oKV/EBBx0XMg1fDlopT -PNbmN7i/zbIG4AsoOyebJZjL7kBzMn1e9vzKHWtcEHXlw/hZGja8vjooAQKBgAeg -xK2HLfg1mCyq5meN/yFQsENu0LzrT5UJzddPgcJw7zqLEqxIKNBAs7Ls8by3yFsL -PQwERa/0jfCl1M6kb9XQNpQa2pw6ANUsWKTDpUJn2wZ+9N3F1RaDwzMWyH5lRVmK -M0qoTfdjpSg5Jwgd75taWt4bxGJWeflSSv8z5R0BAoGAWL8c527AbeBvx2tOYKkD -2TFranvANNcoMrbeviZSkkGvMNDP3p8b6juJwXOIeWNr8q4vFgCzLmq6d1/9gYm2 -3XJwwyD0LKlqzkBrrKU47qrnmMosUrIRlrAzd3HbShOptxc6Iz2apSaUDKGKXkaw -gl5OpEjeliU7Mus0BVS858g= ------END PRIVATE KEY-----`; +import XMLHttpResponseTypeEnum from './XMLHttpResponseTypeEnum'; +import XMLHttpRequestCertificate from './XMLHttpRequestCertificate'; +import XMLHttpRequestSyncRequestScriptBuilder from './utilities/XMLHttpRequestSyncRequestScriptBuilder'; // These headers are not user setable. // The following are allowed but banned in the spec: @@ -115,37 +65,39 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { public static DONE = XMLHttpRequestReadyStateEnum.done; // Public properties - public readyState: XMLHttpRequestReadyStateEnum = XMLHttpRequestReadyStateEnum.unsent; - // Public responseXML = ''; - public responseURL = ''; - public response: ArrayBuffer | Blob | IDocument | object | string = null; - public status: number = null; - public statusText: string = null; public upload: XMLHttpRequestUpload = new XMLHttpRequestUpload(); // Private properties private readonly _ownerDocument: IDocument = null; - private _flags: { - // Async response or sync response. - response: HTTP.IncomingMessage | { headers: string[]; statusCode: number }; - // Response fields - responseType: string; + private _state: { + incommingMessage: HTTP.IncomingMessage | { headers: string[]; statusCode: number }; + response: ArrayBuffer | Blob | IDocument | object | string; + responseType: XMLHttpResponseTypeEnum | ''; responseText: string; responseXML: IDocument; + responseURL: string; + readyState: XMLHttpRequestReadyStateEnum; asyncRequest: HTTP.ClientRequest; asyncTaskID: number; requestHeaders: object; + status: number; + statusText: string; send: boolean; error: boolean; aborted: boolean; } = { + incommingMessage: null, response: null, responseType: '', responseText: '', responseXML: null, + responseURL: '', + readyState: XMLHttpRequestReadyStateEnum.unsent, asyncRequest: null, asyncTaskID: null, requestHeaders: {}, + status: null, + statusText: null, send: false, error: false, aborted: false @@ -174,12 +126,39 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } /** - * Set response text. + * Returns the status. * - * @param value + * @returns Status. */ - public set responseText(value: string) { - this._flags.responseText = value; + public get status(): number { + return this._state.status; + } + + /** + * Returns the status text. + * + * @returns Status text. + */ + public get statusText(): string { + return this._state.statusText; + } + + /** + * Returns the response URL. + * + * @returns Response URL. + */ + public get responseURL(): string { + return this._state.responseURL; + } + + /** + * Returns the ready state. + * + * @returns Ready state. + */ + public get readyState(): XMLHttpRequestReadyStateEnum { + return this._state.readyState; } /** @@ -189,8 +168,8 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns The response text. */ public get responseText(): string { - if (this.responseType === 'text' || this.responseType === '') { - return this._flags.responseText; + if (this.responseType === XMLHttpResponseTypeEnum.text || this.responseType === '') { + return this._state.responseText; } throw new 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}').`, @@ -198,15 +177,6 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ); } - /** - * Sets the response XML. - * - * @param value The response XML. - */ - public set responseXML(value: IDocument) { - this._flags.responseXML = value; - } - /** * Get the responseXML. * @@ -214,8 +184,8 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns Response XML. */ public get responseXML(): IDocument { - if (this.responseType === 'document' || this.responseType === '') { - return this._flags.responseXML; + if (this.responseType === XMLHttpResponseTypeEnum.document || this.responseType === '') { + return this._state.responseXML; } throw new 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}').`, @@ -230,14 +200,14 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @throws {DOMException} If the state is not unsent or opened. * @throws {DOMException} If the request is synchronous. */ - public set responseType(type: string) { + 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 DOMException( - `Failed to set the 'responseType' property on 'XMLHttpRequest': The object's state must be OPENED.`, + `Failed to set the 'responseType' property on 'XMLHttpRequest': The object's state must be OPENED or UNSENT.`, DOMExceptionNameEnum.invalidStateError ); } @@ -248,7 +218,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { DOMExceptionNameEnum.invalidStateError ); } - this._flags.responseType = type; + this._state.responseType = type; } /** @@ -256,8 +226,8 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * * @returns Response type. */ - public get responseType(): string { - return this._flags.responseType; + public get responseType(): XMLHttpResponseTypeEnum | '' { + return this._state.responseType; } /** @@ -271,7 +241,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { */ public open(method: string, url: string, async = true, user?: string, password?: string): void { this.abort(); - this._flags = { ...this._flags, aborted: false, error: false }; + + this._state.aborted = false; + this._state.error = false; const upperMethod = method.toUpperCase(); @@ -279,8 +251,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if (FORBIDDEN_REQUEST_METHODS.includes(upperMethod)) { throw new DOMException('Request method not allowed', DOMExceptionNameEnum.securityError); } + // Check responseType. - if (!async && !!this.responseType && this.responseType !== 'text') { + if (!async && !!this.responseType && this.responseType !== XMLHttpResponseTypeEnum.text) { throw new DOMException( `Failed to execute 'open' on 'XMLHttpRequest': Synchronous requests from a document must not set a response type.`, DOMExceptionNameEnum.invalidAccessError @@ -306,21 +279,25 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns Header added. */ public setRequestHeader(header: string, value: string): boolean { - if (this.readyState != XMLHttpRequestReadyStateEnum.opened) { + if (this.readyState !== XMLHttpRequestReadyStateEnum.opened) { throw new DOMException( `Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED.`, DOMExceptionNameEnum.invalidStateError ); } + const lowerHeader = header.toLowerCase(); if (FORBIDDEN_REQUEST_HEADERS.includes(lowerHeader)) { return false; } - if (this._flags.send) { + + if (this._state.send) { throw new DOMException('send flag is true', DOMExceptionNameEnum.invalidStateError); } - this._flags.requestHeaders[lowerHeader] = value; + + this._state.requestHeaders[lowerHeader] = value; + return true; } @@ -336,10 +313,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if ( typeof header === 'string' && this.readyState > XMLHttpRequestReadyStateEnum.opened && - this._flags.response.headers[lowerHeader] && - !this._flags.error + this._state.incommingMessage.headers[lowerHeader] && + !this._state.error ) { - return this._flags.response.headers[lowerHeader]; + return this._state.incommingMessage.headers[lowerHeader]; } return null; @@ -351,15 +328,15 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns A string with all response headers separated by CR+LF. */ public getAllResponseHeaders(): string { - if (this.readyState < XMLHttpRequestReadyStateEnum.headersRecieved || this._flags.error) { + if (this.readyState < XMLHttpRequestReadyStateEnum.headersRecieved || this._state.error) { return ''; } let result = ''; - for (const name of Object.keys(this._flags.response.headers)) { + for (const name of Object.keys(this._state.incommingMessage.headers)) { // Cookie headers are excluded if (name !== 'set-cookie' && name !== 'set-cookie2') { - result += `${name}: ${this._flags.response.headers[name]}\r\n`; + result += `${name}: ${this._state.incommingMessage.headers[name]}\r\n`; } } @@ -374,8 +351,8 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { */ public getRequestHeader(name: string): string { const lowerName = name.toLowerCase(); - if (typeof name === 'string' && this._flags.requestHeaders[lowerName]) { - return this._flags.requestHeaders[lowerName]; + if (typeof name === 'string' && this._state.requestHeaders[lowerName]) { + return this._state.requestHeaders[lowerName]; } return ''; @@ -394,7 +371,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ); } - if (this._flags.send) { + if (this._state.send) { throw new DOMException( 'send has already been called', DOMExceptionNameEnum.invalidStateError @@ -435,32 +412,32 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { const uri = url.pathname + (url.search ? url.search : ''); // Set the Host header or the server may reject the request - this._flags.requestHeaders['host'] = host; + this._state.requestHeaders['host'] = host; if (!((ssl && port === 443) || port === 80)) { - this._flags.requestHeaders['host'] += ':' + url.port; + this._state.requestHeaders['host'] += ':' + url.port; } // Set Basic Auth if necessary if (this._settings.user) { this._settings.password ??= ''; const authBuffer = Buffer.from(this._settings.user + ':' + this._settings.password); - this._flags.requestHeaders['authorization'] = 'Basic ' + authBuffer.toString('base64'); + this._state.requestHeaders['authorization'] = 'Basic ' + authBuffer.toString('base64'); } // Set the Content-Length header if method is POST if (this._settings.method === 'GET' || this._settings.method === 'HEAD') { data = null; } else if (this._settings.method === 'POST') { // Set default content type if not set. - if (!this._flags.requestHeaders['content-type']) { - this._flags.requestHeaders['content-type'] = 'text/plain;charset=UTF-8'; + if (!this._state.requestHeaders['content-type']) { + this._state.requestHeaders['content-type'] = 'text/plain;charset=UTF-8'; } if (data) { - this._flags.requestHeaders['content-length'] = Buffer.isBuffer(data) + this._state.requestHeaders['content-length'] = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data); } else { - this._flags.requestHeaders['content-length'] = 0; + this._state.requestHeaders['content-length'] = 0; } } @@ -469,15 +446,15 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { port: port, path: uri, method: this._settings.method, - headers: { ...this._getDefaultRequestHeaders(), ...this._flags.requestHeaders }, + headers: { ...this._getDefaultRequestHeaders(), ...this._state.requestHeaders }, agent: false, rejectUnauthorized: true, - key: ssl ? SSL_KEY : null, - cert: ssl ? SSL_CERT : null + key: ssl ? XMLHttpRequestCertificate.key : null, + cert: ssl ? XMLHttpRequestCertificate.cert : null }; // Reset error flag - this._flags.error = false; + this._state.error = false; // Handle async requests if (this._settings.async) { @@ -491,29 +468,31 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * Aborts a request. */ public abort(): void { - if (this._flags.asyncRequest) { - this._flags.asyncRequest.destroy(); - this._flags.asyncRequest = null; + if (this._state.asyncRequest) { + this._state.asyncRequest.destroy(); + this._state.asyncRequest = null; } - this._flags.requestHeaders = {}; - this.responseText = ''; - this.responseXML = null; - - this._flags = { ...this._flags, aborted: true, error: true }; + this._state.status = null; + this._state.statusText = null; + this._state.requestHeaders = {}; + this._state.responseText = ''; + this._state.responseXML = null; + this._state.aborted = true; + this._state.error = true; if ( this.readyState !== XMLHttpRequestReadyStateEnum.unsent && - (this.readyState !== XMLHttpRequestReadyStateEnum.opened || this._flags.send) && + (this.readyState !== XMLHttpRequestReadyStateEnum.opened || this._state.send) && this.readyState !== XMLHttpRequestReadyStateEnum.done ) { - this._flags.send = false; + this._state.send = false; this._setState(XMLHttpRequestReadyStateEnum.done); } - this.readyState = XMLHttpRequestReadyStateEnum.unsent; + this._state.readyState = XMLHttpRequestReadyStateEnum.unsent; - if (this._flags.asyncTaskID !== null) { - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._flags.asyncTaskID); + if (this._state.asyncTaskID !== null) { + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._state.asyncTaskID); } } @@ -525,12 +504,12 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { private _setState(state: XMLHttpRequestReadyStateEnum): void { if ( this.readyState === state || - (this.readyState === XMLHttpRequestReadyStateEnum.unsent && this._flags.aborted) + (this.readyState === XMLHttpRequestReadyStateEnum.unsent && this._state.aborted) ) { return; } - this.readyState = state; + this._state.readyState = state; if ( this._settings.async || @@ -543,9 +522,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if (this.readyState === XMLHttpRequestReadyStateEnum.done) { let fire: Event; - if (this._flags.aborted) { + if (this._state.aborted) { fire = new Event('abort'); - } else if (this._flags.error) { + } else if (this._state.error) { fire = new Event('error'); } else { fire = new Event('load'); @@ -564,7 +543,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { private _getDefaultRequestHeaders(): { [key: string]: string } { return { accept: '*/*', - referer: this._ownerDocument.defaultView.location.origin, + referer: this._ownerDocument.defaultView.location.href, 'user-agent': this._ownerDocument.defaultView.navigator.userAgent, cookie: this._ownerDocument.defaultView.document.cookie }; @@ -578,7 +557,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param data */ private _sendSyncRequest(options: HTTPS.RequestOptions, ssl: boolean, data?: string): void { - const scriptString = this._getSyncRequestScriptString(options, ssl, data); + const scriptString = XMLHttpRequestSyncRequestScriptBuilder.getScript(options, ssl, data); // Start the other Node Process, executing this string const content = ChildProcess.execFileSync(process.argv[0], ['-e', scriptString], { @@ -597,32 +576,36 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } if (responseObj) { - this._flags.response = { statusCode: responseObj.statusCode, headers: responseObj.headers }; - this.status = responseObj.statusCode; + this._state.incommingMessage = { + statusCode: responseObj.statusCode, + headers: responseObj.headers + }; + this._state.status = responseObj.statusCode; + this._state.statusText = responseObj.statusMessage; // Sync responseType === '' - this.response = responseObj.text; - this.responseText = responseObj.text; - this.responseXML = null; - this.responseURL = RelativeURL.getAbsoluteURL( + this._state.response = responseObj.text; + this._state.responseText = responseObj.text; + this._state.responseXML = null; + this._state.responseURL = RelativeURL.getAbsoluteURL( this._ownerDocument.defaultView.location, this._settings.url ).href; // Set Cookies. - if (this._flags.response.headers['set-cookie']) { + if (this._state.incommingMessage.headers['set-cookie']) { // TODO: Bugs in CookieJar. this._ownerDocument.defaultView.document.cookie = - this._flags.response.headers['set-cookie']; + this._state.incommingMessage.headers['set-cookie']; } // Redirect. if ( - this._flags.response.statusCode === 301 || - this._flags.response.statusCode === 302 || - this._flags.response.statusCode === 303 || - this._flags.response.statusCode === 307 + this._state.incommingMessage.statusCode === 301 || + this._state.incommingMessage.statusCode === 302 || + this._state.incommingMessage.statusCode === 303 || + this._state.incommingMessage.statusCode === 307 ) { const redirectUrl = RelativeURL.getAbsoluteURL( this._ownerDocument.defaultView.location, - this._flags.response.headers['location'] + this._state.incommingMessage.headers['location'] ); ssl = redirectUrl.protocol === 'https:'; this._settings.url = redirectUrl.href; @@ -632,7 +615,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { host: redirectUrl.host, path: redirectUrl.pathname + (redirectUrl.search ?? ''), port: redirectUrl.port || (ssl ? 443 : 80), - method: this._flags.response.statusCode === 303 ? 'GET' : this._settings.method, + method: this._state.incommingMessage.statusCode === 303 ? 'GET' : this._settings.method, headers: Object.assign(options.headers, { referer: redirectUrl.origin, host: redirectUrl.host @@ -647,48 +630,6 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } } - /** - * Sends a synchronous request. - * - * @param options Options. - * @param ssl SSL. - * @param data Data. - */ - private _getSyncRequestScriptString( - options: HTTPS.RequestOptions, - ssl: boolean, - data?: string - ): string { - // Synchronous - // Note: console.log === stdout - // The async request the other Node process executes - return ` - const HTTP = require('http'); - const HTTPS = require('https'); - const sendRequest = HTTP${ssl ? 'S' : ''}.request; - const options = ${JSON.stringify(options)}; - const request = sendRequest(options, (response) => { - let responseText = ''; - let responseData = Buffer.alloc(0); - response.setEncoding('utf8'); - response.on('data', (chunk) => { - responseText += chunk; - responseData = Buffer.concat([responseData, Buffer.from(chunk)]); - }); - response.on('end', () => { - console.log(JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, text: responseText, data: responseData.toString('base64')}})); - }); - response.on('error', (error) => { - console.log(JSON.stringify({err: error, data: null})); - }); - }); - request.write(\`${JSON.stringify(data ?? '') - .slice(1, -1) - .replace(/'/g, "\\'")}\`); - request.end(); - `; - } - /** * Sends an async request. * @@ -703,7 +644,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ): Promise { return new Promise((resolve) => { // Starts async task in Happy DOM - this._flags.asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask( + this._state.asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask( this.abort.bind(this) ); @@ -711,13 +652,13 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { const sendRequest = ssl ? HTTPS.request : HTTP.request; // Request is being sent, set send flag - this._flags.send = true; + this._state.send = true; // As per spec, this is called here for historical reasons. this.dispatchEvent(new Event('readystatechange')); // Create the request - this._flags.asyncRequest = sendRequest( + this._state.asyncRequest = sendRequest( options, async (response: HTTP.IncomingMessage) => { await this._onAsyncResponse(response, options, ssl, data); @@ -730,15 +671,15 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Node 0.4 and later won't accept empty data. Make sure it's needed. if (data) { - this._flags.asyncRequest.write(data); + this._state.asyncRequest.write(data); } - this._flags.asyncRequest.end(); + this._state.asyncRequest.end(); this.dispatchEvent(new Event('loadstart')); // Ends async task in Happy DOM - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._flags.asyncTaskID); + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._state.asyncTaskID); }); } @@ -760,19 +701,19 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { return new Promise((resolve) => { // Set response var to the response we got back // This is so it remains accessable outside this scope - this._flags.response = response; + this._state.incommingMessage = response; // Check for redirect // @TODO Prevent looped redirects if ( - this._flags.response.statusCode === 301 || - this._flags.response.statusCode === 302 || - this._flags.response.statusCode === 303 || - this._flags.response.statusCode === 307 + this._state.incommingMessage.statusCode === 301 || + this._state.incommingMessage.statusCode === 302 || + this._state.incommingMessage.statusCode === 303 || + this._state.incommingMessage.statusCode === 307 ) { // TODO: redirect url protocol change. // Change URL to the redirect location - this._settings.url = this._flags.response.headers.location; + this._settings.url = this._state.incommingMessage.headers.location; // Parse the new URL. const redirectUrl = RelativeURL.getAbsoluteURL( this._ownerDocument.defaultView.location, @@ -787,7 +728,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { host: redirectUrl.hostname, port: redirectUrl.port, path: redirectUrl.pathname + (redirectUrl.search ?? ''), - method: this._flags.response.statusCode === 303 ? 'GET' : this._settings.method, + method: this._state.incommingMessage.statusCode === 303 ? 'GET' : this._settings.method, headers: { ...options.headers, referer: redirectUrl.origin, host: redirectUrl.host } }, ssl, @@ -797,27 +738,28 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { return; } - if (this._flags.response && this._flags.response.setEncoding) { - this._flags.response.setEncoding('utf-8'); + if (this._state.incommingMessage && this._state.incommingMessage.setEncoding) { + this._state.incommingMessage.setEncoding('utf-8'); } this._setState(XMLHttpRequestReadyStateEnum.headersRecieved); - this.status = this._flags.response.statusCode; + this._state.status = this._state.incommingMessage.statusCode; + this._state.statusText = this._state.incommingMessage.statusMessage; // Initialize response. let tempResponse = Buffer.from(new Uint8Array(0)); - this._flags.response.on('data', (chunk: Uint8Array) => { + this._state.incommingMessage.on('data', (chunk: Uint8Array) => { // Make sure there's some data if (chunk) { tempResponse = Buffer.concat([tempResponse, Buffer.from(chunk)]); } // Don't emit state changes if the connection has been aborted. - if (this._flags.send) { + if (this._state.send) { this._setState(XMLHttpRequestReadyStateEnum.loading); } - const contentLength = Number(this._flags.response.headers['content-length']); + const contentLength = Number(this._state.incommingMessage.headers['content-length']); this.dispatchEvent( new ProgressEvent('progress', { lengthComputable: isNaN(contentLength) ? false : true, @@ -827,18 +769,18 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { ); }); - this._flags.response.on('end', () => { - if (this._flags.send) { + this._state.incommingMessage.on('end', () => { + if (this._state.send) { // The sendFlag needs to be set before setState is called. Otherwise, if we are chaining callbacks // There can be a timing issue (the callback is called and a new call is made before the flag is reset). - this._flags.send = false; + this._state.send = false; // Set response according to responseType. const { response, responseXML, responseText } = this._parseResponseData(tempResponse); - this.response = response; - this.responseXML = responseXML; - this.responseText = responseText; - this.responseURL = RelativeURL.getAbsoluteURL( + this._state.response = response; + this._state.responseXML = responseXML; + this._state.responseText = responseText; + this._state.responseURL = RelativeURL.getAbsoluteURL( this._ownerDocument.defaultView.location, this._settings.url ).href; @@ -849,7 +791,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { resolve(); }); - this._flags.response.on('error', (error) => { + this._state.incommingMessage.on('error', (error) => { this._onError(error); resolve(); }); @@ -863,7 +805,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns Promise. */ private async _sendLocalAsyncRequest(url: UrlObject): Promise { - this._flags.asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask( + this._state.asyncTaskID = this._ownerDocument.defaultView.happyDOM.asyncTaskManager.startTask( this.abort.bind(this) ); @@ -893,7 +835,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._parseLocalRequestData(data); } - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._flags.asyncTaskID); + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._state.asyncTaskID); } /** @@ -934,13 +876,14 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param data Data. */ private _parseLocalRequestData(data: Buffer): void { - this.status = 200; + this._state.status = 200; + this._state.statusText = 'OK'; const { response, responseXML, responseText } = this._parseResponseData(data); - this.response = response; - this.responseXML = responseXML; - this.responseText = responseText; - this.responseURL = RelativeURL.getAbsoluteURL( + this._state.response = response; + this._state.responseXML = responseXML; + this._state.responseText = responseText; + this._state.responseURL = RelativeURL.getAbsoluteURL( this._ownerDocument.defaultView.location, this._settings.url ).href; @@ -960,7 +903,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { responseXML: IDocument; } { switch (this.responseType) { - case 'arraybuffer': + case XMLHttpResponseTypeEnum.arraybuffer: // See: https://github.com/jsdom/jsdom/blob/c3c421c364510e053478520500bccafd97f5fa39/lib/jsdom/living/helpers/binary-data.js const newAB = new ArrayBuffer(data.length); const view = new Uint8Array(newAB); @@ -970,7 +913,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { responseText: null, responseXML: null }; - case 'blob': + case XMLHttpResponseTypeEnum.blob: try { return { response: new this._ownerDocument.defaultView.Blob([new Uint8Array(data)], { @@ -982,7 +925,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } catch (e) { return { response: null, responseText: null, responseXML: null }; } - case 'document': + case XMLHttpResponseTypeEnum.document: const window = this._ownerDocument.defaultView; const happyDOMSettings = window.happyDOM.settings; let response: IDocument; @@ -1008,7 +951,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { window.happyDOM.settings = happyDOMSettings; return { response, responseText: null, responseXML: response }; - case 'json': + case XMLHttpResponseTypeEnum.json: try { return { response: JSON.parse(data.toString()), @@ -1018,7 +961,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } catch (e) { return { response: null, responseText: null, responseXML: null }; } - case 'text': + case XMLHttpResponseTypeEnum.text: case '': default: return { @@ -1036,10 +979,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param status HTTP status code to use rather than the default (0) for XHR errors. */ private _onError(error: Error | string, status = 0): void { - this.status = status; - this.statusText = error.toString(); - this.responseText = error instanceof Error ? error.stack : ''; - this._flags.error = true; + this._state.status = status; + this._state.statusText = error.toString(); + this._state.responseText = error instanceof Error ? error.stack : ''; + this._state.error = true; this._setState(XMLHttpRequestReadyStateEnum.done); } } diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestCertificate.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestCertificate.ts new file mode 100644 index 000000000..02be3f61a --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestCertificate.ts @@ -0,0 +1,52 @@ +// SSL certificate generated for Happy DOM to be able to perform HTTPS requests +export default { + cert: `-----BEGIN CERTIFICATE----- + MIIDYzCCAkugAwIBAgIUJRKB/H66hpet1VfUlm0CiXqePA4wDQYJKoZIhvcNAQEL + BQAwQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVNYWxt + bzESMBAGA1UECgwJSGFwcHkgRE9NMB4XDTIyMTAxMTIyMDM0OVoXDTMyMTAwODIy + MDM0OVowQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVN + YWxtbzESMBAGA1UECgwJSGFwcHkgRE9NMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A + MIIBCgKCAQEAqerQSQEg/SxVxRiwlItithr5e5EMZo1nsqt/xOxagbmpW3IEmt0j + bpbH7iEF4DDEo7KAOwUCOwVWeFxRoag8lG2ax48wrgjlCna45XDn0Xeg1ARajL04 + gs46HZ0VrzIloVGfln0zgt/Vum5BNqs9Oc5fQoBmoP3cAn3dn4ZVcP0AKthtcyPl + q2DuNRN0PV0D2RtMSiAy9l1Ko6N5x+sAeClDyOL+sTDLngZBVeZyOKt9Id15S8Zt + XtA6VMgHnnF3jChn7pag77rsd/y5iANAVNZYqRl+Eg7xaDcsvbgH46UBOrBcB39Q + tTh5Mtjoxep5e3ZDFG+kQ1HUE+iz5O5n0wIDAQABo1MwUTAdBgNVHQ4EFgQU69s9 + YSobG/m2SN4L/7zTaF7iDbwwHwYDVR0jBBgwFoAU69s9YSobG/m2SN4L/7zTaF7i + DbwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAi/WUXx2oal8L + YnPlIuKfh49n/K18wXSYG//oFYwxfVxqpYH8hUiXVm/GUcXCxS++hUkaKLqXmH9q + MKJiCrZr3vS+2nsBKopkICu/TLdROl0sAI9lByfnEbfSAzjxe1IWJdK8NdY0y5m5 + 9pEr/URVIAp/CxrneyASb4q0Jg5To3FR7vYc+2X6wZn0MundKMg6Dp9/A37jiF3l + Tt/EJp299YZcsUzh+LnRuggRjnoOVu1aLcLFlaUiwZfy9m8mLG6B/mdW/qNzNMh9 + Oqvg1zfGdpz/4D/2UUUBn6pq1vbsoAaF3OesoA3mfDcegDf/H9woJlpT0Wql+e68 + Y3FblSokcA== + -----END CERTIFICATE-----`, + key: `-----BEGIN PRIVATE KEY----- + MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCp6tBJASD9LFXF + GLCUi2K2Gvl7kQxmjWeyq3/E7FqBualbcgSa3SNulsfuIQXgMMSjsoA7BQI7BVZ4 + XFGhqDyUbZrHjzCuCOUKdrjlcOfRd6DUBFqMvTiCzjodnRWvMiWhUZ+WfTOC39W6 + bkE2qz05zl9CgGag/dwCfd2fhlVw/QAq2G1zI+WrYO41E3Q9XQPZG0xKIDL2XUqj + o3nH6wB4KUPI4v6xMMueBkFV5nI4q30h3XlLxm1e0DpUyAeecXeMKGfulqDvuux3 + /LmIA0BU1lipGX4SDvFoNyy9uAfjpQE6sFwHf1C1OHky2OjF6nl7dkMUb6RDUdQT + 6LPk7mfTAgMBAAECggEAKkwTkTjAt4UjzK56tl+EMQTB+ep/hb/JgoaChci4Nva6 + m9LkJpDJ0yuhlTuPNOGu8XjrxsVWas7HWarRf0Zb3i7yip6wZYI9Ub+AA015x4DZ + /i0fRU2NFbK0cM67qSL4jxG8gj+kZP3HPGNZxHwX/53JxMolwgmvjMc8NgvAlSFd + NnV9h4xtbhUh1NGS5zmP3iU2rwnE8JrIEzwy6axLom7nekAgkdcbAr0UoBs8gcgH + aYNhU4Gz3tGcZZ0IXAfT/bJIH1Ko8AGv4pssWc3BXcmmNdm/+kzvHIxEIV7Qegmo + XG1ZyZCyD/0b4/3e8ySDBEDqwR+HeyTW2isWG2agAQKBgQDp44aTwr3dkIXY30xv + FPfUOipg/B49dWnffYJ9MWc1FT9ijNPAngWSk0EIiEQIazICcUBI4Yji6/KeyqLJ + GdLpDi1CkKqtyh73mjELinYp3EUQgEa77aQogGa2+nMOVfu+O5CtloUrv/A18jX3 + +VEyaEASK0fWmnSI0OdlxQHIAQKBgQC5+xOls2F3MlKASvWRLlnW1wHqlDTtVoYg + 5Nh8syZH4Ci2UH8tON3A5/7SWNM0t1cgV6Cw4zW8Z2spgIT/W0iYYrQ4hHL1xdCu + +CxL1km4Gy8Uwpsd+KdFahFqF/XTmLzW0HXLxWSK0fTwmdV0SFrKF3MXfTCU2AeZ + jJoMFb6P0wKBgQC3Odw6s0vkYAzLGhuZxfZkVvDOK5RRF0NKpttr0iEFL9EJFkPo + 2KKK8jr3QTDy229BBJGUxsJi6u6VwS8HlehpVQbV59kd7oKV/EBBx0XMg1fDlopT + PNbmN7i/zbIG4AsoOyebJZjL7kBzMn1e9vzKHWtcEHXlw/hZGja8vjooAQKBgAeg + xK2HLfg1mCyq5meN/yFQsENu0LzrT5UJzddPgcJw7zqLEqxIKNBAs7Ls8by3yFsL + PQwERa/0jfCl1M6kb9XQNpQa2pw6ANUsWKTDpUJn2wZ+9N3F1RaDwzMWyH5lRVmK + M0qoTfdjpSg5Jwgd75taWt4bxGJWeflSSv8z5R0BAoGAWL8c527AbeBvx2tOYKkD + 2TFranvANNcoMrbeviZSkkGvMNDP3p8b6juJwXOIeWNr8q4vFgCzLmq6d1/9gYm2 + 3XJwwyD0LKlqzkBrrKU47qrnmMosUrIRlrAzd3HbShOptxc6Iz2apSaUDKGKXkaw + gl5OpEjeliU7Mus0BVS858g= + -----END PRIVATE KEY-----` +}; diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpResponseTypeEnum.ts b/packages/happy-dom/src/xml-http-request/XMLHttpResponseTypeEnum.ts new file mode 100644 index 000000000..553b32cca --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/XMLHttpResponseTypeEnum.ts @@ -0,0 +1,9 @@ +enum XMLHttpResponseTypeEnum { + arraybuffer = 'arraybuffer', + blob = 'blob', + document = 'document', + json = 'json', + text = 'text' +} + +export default XMLHttpResponseTypeEnum; diff --git a/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder.ts b/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder.ts new file mode 100644 index 000000000..3b0a92f24 --- /dev/null +++ b/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder.ts @@ -0,0 +1,44 @@ +import HTTPS from 'https'; + +/** + * Synchroneous XMLHttpRequest script builder. + */ +export default class XMLHttpRequestSyncRequestScriptBuilder { + /** + * Sends a synchronous request. + * + * @param options Options. + * @param ssl SSL. + * @param data Data. + */ + public static getScript(options: HTTPS.RequestOptions, ssl: boolean, data?: string): string { + // Synchronous + // Note: console.log === stdout + // The async request the other Node process executes + return ` + const HTTP = require('http'); + const HTTPS = require('https'); + const sendRequest = HTTP${ssl ? 'S' : ''}.request; + const options = ${JSON.stringify(options)}; + const request = sendRequest(options, (response) => { + let responseText = ''; + let responseData = Buffer.alloc(0); + response.setEncoding('utf8'); + response.on('data', (chunk) => { + responseText += chunk; + responseData = Buffer.concat([responseData, Buffer.from(chunk)]); + }); + response.on('end', () => { + console.log(JSON.stringify({err: null, data: {statusCode: response.statusCode, statusMessage: response.statusMessage, headers: response.headers, text: responseText, data: responseData.toString('base64')}})); + }); + response.on('error', (error) => { + console.log(JSON.stringify({err: error, data: null})); + }); + }); + request.write(\`${JSON.stringify(data ?? '') + .slice(1, -1) + .replace(/'/g, "\\'")}\`); + request.end(); + `; + } +} diff --git a/packages/happy-dom/test/fetch/ResourceFetchHandler.test.ts b/packages/happy-dom/test/fetch/ResourceFetchHandler.test.ts index c3aff218d..b9862724a 100644 --- a/packages/happy-dom/test/fetch/ResourceFetchHandler.test.ts +++ b/packages/happy-dom/test/fetch/ResourceFetchHandler.test.ts @@ -3,6 +3,8 @@ import IWindow from '../../src/window/IWindow'; import IDocument from '../../src/nodes/document/IDocument'; import ResourceFetchHandler from '../../src/fetch/ResourceFetchHandler'; import IResponse from '../../src/fetch/IResponse'; +import XMLHttpRequestSyncRequestScriptBuilder from '../../src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder'; +import XMLHttpRequestCertificate from '../../src/xml-http-request/XMLHttpRequestCertificate'; const URL = 'https://localhost:8080/base/'; @@ -46,32 +48,27 @@ describe('ResourceFetchHandler', () => { process.argv[0] ); expect(mockedModules.modules.child_process.execFileSync.parameters.args[0]).toBe('-e'); - expect( - mockedModules.modules.child_process.execFileSync.parameters.args[1].replace(/\s/gm, '') - ).toBe( - ` - const HTTP = require('http'); - const HTTPS = require('https'); - const sendRequest = HTTPS.request; - const options = {\"host\":\"localhost\",\"port\":8080,\"path\":\"/base/path/to/script/\",\"method\":\"GET\",\"headers\":{\"accept\":\"*/*\",\"referer\":\"https://localhost:8080\",\"user-agent\":\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:97.0) Gecko/20100101 Firefox/97.0\",\"cookie\":\"\",\"host\":\"localhost:8080\"},\"agent\":false,\"rejectUnauthorized\":true,\"key\":\"-----BEGIN PRIVATE KEY-----\\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCp6tBJASD9LFXF\\nGLCUi2K2Gvl7kQxmjWeyq3/E7FqBualbcgSa3SNulsfuIQXgMMSjsoA7BQI7BVZ4\\nXFGhqDyUbZrHjzCuCOUKdrjlcOfRd6DUBFqMvTiCzjodnRWvMiWhUZ+WfTOC39W6\\nbkE2qz05zl9CgGag/dwCfd2fhlVw/QAq2G1zI+WrYO41E3Q9XQPZG0xKIDL2XUqj\\no3nH6wB4KUPI4v6xMMueBkFV5nI4q30h3XlLxm1e0DpUyAeecXeMKGfulqDvuux3\\n/LmIA0BU1lipGX4SDvFoNyy9uAfjpQE6sFwHf1C1OHky2OjF6nl7dkMUb6RDUdQT\\n6LPk7mfTAgMBAAECggEAKkwTkTjAt4UjzK56tl+EMQTB+ep/hb/JgoaChci4Nva6\\nm9LkJpDJ0yuhlTuPNOGu8XjrxsVWas7HWarRf0Zb3i7yip6wZYI9Ub+AA015x4DZ\\n/i0fRU2NFbK0cM67qSL4jxG8gj+kZP3HPGNZxHwX/53JxMolwgmvjMc8NgvAlSFd\\nNnV9h4xtbhUh1NGS5zmP3iU2rwnE8JrIEzwy6axLom7nekAgkdcbAr0UoBs8gcgH\\naYNhU4Gz3tGcZZ0IXAfT/bJIH1Ko8AGv4pssWc3BXcmmNdm/+kzvHIxEIV7Qegmo\\nXG1ZyZCyD/0b4/3e8ySDBEDqwR+HeyTW2isWG2agAQKBgQDp44aTwr3dkIXY30xv\\nFPfUOipg/B49dWnffYJ9MWc1FT9ijNPAngWSk0EIiEQIazICcUBI4Yji6/KeyqLJ\\nGdLpDi1CkKqtyh73mjELinYp3EUQgEa77aQogGa2+nMOVfu+O5CtloUrv/A18jX3\\n+VEyaEASK0fWmnSI0OdlxQHIAQKBgQC5+xOls2F3MlKASvWRLlnW1wHqlDTtVoYg\\n5Nh8syZH4Ci2UH8tON3A5/7SWNM0t1cgV6Cw4zW8Z2spgIT/W0iYYrQ4hHL1xdCu\\n+CxL1km4Gy8Uwpsd+KdFahFqF/XTmLzW0HXLxWSK0fTwmdV0SFrKF3MXfTCU2AeZ\\njJoMFb6P0wKBgQC3Odw6s0vkYAzLGhuZxfZkVvDOK5RRF0NKpttr0iEFL9EJFkPo\\n2KKK8jr3QTDy229BBJGUxsJi6u6VwS8HlehpVQbV59kd7oKV/EBBx0XMg1fDlopT\\nPNbmN7i/zbIG4AsoOyebJZjL7kBzMn1e9vzKHWtcEHXlw/hZGja8vjooAQKBgAeg\\nxK2HLfg1mCyq5meN/yFQsENu0LzrT5UJzddPgcJw7zqLEqxIKNBAs7Ls8by3yFsL\\nPQwERa/0jfCl1M6kb9XQNpQa2pw6ANUsWKTDpUJn2wZ+9N3F1RaDwzMWyH5lRVmK\\nM0qoTfdjpSg5Jwgd75taWt4bxGJWeflSSv8z5R0BAoGAWL8c527AbeBvx2tOYKkD\\n2TFranvANNcoMrbeviZSkkGvMNDP3p8b6juJwXOIeWNr8q4vFgCzLmq6d1/9gYm2\\n3XJwwyD0LKlqzkBrrKU47qrnmMosUrIRlrAzd3HbShOptxc6Iz2apSaUDKGKXkaw\\ngl5OpEjeliU7Mus0BVS858g=\\n-----END PRIVATE KEY-----\",\"cert\":\"-----BEGIN CERTIFICATE-----\\nMIIDYzCCAkugAwIBAgIUJRKB/H66hpet1VfUlm0CiXqePA4wDQYJKoZIhvcNAQEL\\nBQAwQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVNYWxt\\nbzESMBAGA1UECgwJSGFwcHkgRE9NMB4XDTIyMTAxMTIyMDM0OVoXDTMyMTAwODIy\\nMDM0OVowQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVN\\nYWxtbzESMBAGA1UECgwJSGFwcHkgRE9NMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A\\nMIIBCgKCAQEAqerQSQEg/SxVxRiwlItithr5e5EMZo1nsqt/xOxagbmpW3IEmt0j\\nbpbH7iEF4DDEo7KAOwUCOwVWeFxRoag8lG2ax48wrgjlCna45XDn0Xeg1ARajL04\\ngs46HZ0VrzIloVGfln0zgt/Vum5BNqs9Oc5fQoBmoP3cAn3dn4ZVcP0AKthtcyPl\\nq2DuNRN0PV0D2RtMSiAy9l1Ko6N5x+sAeClDyOL+sTDLngZBVeZyOKt9Id15S8Zt\\nXtA6VMgHnnF3jChn7pag77rsd/y5iANAVNZYqRl+Eg7xaDcsvbgH46UBOrBcB39Q\\ntTh5Mtjoxep5e3ZDFG+kQ1HUE+iz5O5n0wIDAQABo1MwUTAdBgNVHQ4EFgQU69s9\\nYSobG/m2SN4L/7zTaF7iDbwwHwYDVR0jBBgwFoAU69s9YSobG/m2SN4L/7zTaF7i\\nDbwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAi/WUXx2oal8L\\nYnPlIuKfh49n/K18wXSYG//oFYwxfVxqpYH8hUiXVm/GUcXCxS++hUkaKLqXmH9q\\nMKJiCrZr3vS+2nsBKopkICu/TLdROl0sAI9lByfnEbfSAzjxe1IWJdK8NdY0y5m5\\n9pEr/URVIAp/CxrneyASb4q0Jg5To3FR7vYc+2X6wZn0MundKMg6Dp9/A37jiF3l\\nTt/EJp299YZcsUzh+LnRuggRjnoOVu1aLcLFlaUiwZfy9m8mLG6B/mdW/qNzNMh9\\nOqvg1zfGdpz/4D/2UUUBn6pq1vbsoAaF3OesoA3mfDcegDf/H9woJlpT0Wql+e68\\nY3FblSokcA==\\n-----END CERTIFICATE-----\"}; - const request = sendRequest(options, (response) => { - let responseText = ''; - let responseData = Buffer.alloc(0); - response.setEncoding('utf8'); - response.on('data', (chunk) => { - responseText += chunk; - responseData = Buffer.concat([responseData, Buffer.from(chunk)]); - }); - response.on('end', () => { - console.log(JSON.stringify({err: null, data: { statusCode: response.statusCode, headers: response.headers, text: responseText, data: responseData.toString('base64')}})); - }); - response.on('error', (error) => { - console.log(JSON.stringify({err: error, data: null})); - }); - }); - request.write(\`\`); - request.end(); - `.replace(/\s/gm, '') + expect(mockedModules.modules.child_process.execFileSync.parameters.args[1]).toBe( + XMLHttpRequestSyncRequestScriptBuilder.getScript( + { + host: 'localhost', + port: 8080, + path: '/base/path/to/script/', + method: 'GET', + headers: { + accept: '*/*', + referer: 'https://localhost:8080/base/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: 'localhost:8080' + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }, + true + ) ); expect(mockedModules.modules.child_process.execFileSync.parameters.options).toEqual({ encoding: 'buffer', diff --git a/packages/happy-dom/test/setup.js b/packages/happy-dom/test/setup.js index d28afb368..0f642a494 100644 --- a/packages/happy-dom/test/setup.js +++ b/packages/happy-dom/test/setup.js @@ -48,6 +48,7 @@ const modules = { returnValue: { data: { statusCode: 200, + statusMessage: 'child_process.execFileSync.returnValue.data.statusMessage', headers: { key1: 'value1', key2: 'value2' }, text: 'child_process.execFileSync.returnValue.data.text', data: Buffer.from('child_process.execFileSync.returnValue.data.text').toString('base64') @@ -67,7 +68,7 @@ const modules = { returnValue: { response: { headers: 'http.request.headers', - statusCode: 'http.request.statusCode', + statusCode: 200, statusMessage: 'http.request.statusMessage', body: 'http.request.body', error: null diff --git a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts index a1af7558a..307e16614 100644 --- a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts +++ b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts @@ -2,15 +2,22 @@ import XMLHttpRequest from '../../src/xml-http-request/XMLHttpRequest'; import Window from '../../src/window/Window'; import IWindow from '../../src/window/IWindow'; import XMLHttpRequestReadyStateEnum from '../../src/xml-http-request/XMLHttpRequestReadyStateEnum'; +import XMLHttpResponseTypeEnum from '../../src/xml-http-request/XMLHttpResponseTypeEnum'; +import XMLHttpRequestSyncRequestScriptBuilder from '../../src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder'; +import XMLHttpRequestCertificate from '../../src/xml-http-request/XMLHttpRequestCertificate'; -const URL = 'https://localhost:8080'; +const WINDOW_URL = 'https://localhost:8080'; +const REQUEST_URL = '/path/to/resource/'; +const FORBIDDEN_REQUEST_METHODS = ['TRACE', 'TRACK', 'CONNECT']; describe('XMLHttpRequest', () => { let window: IWindow; let request: XMLHttpRequest; beforeEach(() => { - window = new Window(); + window = new Window({ + url: WINDOW_URL + }); request = new window.XMLHttpRequest(); }); @@ -18,11 +25,83 @@ describe('XMLHttpRequest', () => { mockedModules.reset(); }); + describe('get status()', () => { + it('Returns status for synchrounous requests.', () => { + expect(request.status).toBe(null); + + request.open('GET', REQUEST_URL, false); + request.send(); + + expect(request.status).toBe(200); + }); + + it('Returns status for asynchrounous requests.', (done) => { + expect(request.status).toBe(null); + + request.open('GET', REQUEST_URL, true); + + request.addEventListener('load', () => { + expect(request.status).toBe(200); + done(); + }); + + request.send(); + }); + }); + + describe('get statusText()', () => { + it('Returns status text for synchrounous requests.', () => { + expect(request.statusText).toBe(null); + + request.open('GET', REQUEST_URL, false); + request.send(); + + expect(request.statusText).toBe('child_process.execFileSync.returnValue.data.statusMessage'); + }); + + it('Returns status text for asynchrounous requests.', (done) => { + expect(request.statusText).toBe(null); + + request.open('GET', REQUEST_URL, true); + + request.addEventListener('load', () => { + expect(request.statusText).toBe('http.request.statusMessage'); + done(); + }); + + request.send(); + }); + }); + + describe('get responseURL()', () => { + it('Returns response URL for synchrounous requests.', () => { + expect(request.responseURL).toBe(''); + + request.open('GET', REQUEST_URL, false); + request.send(); + + expect(request.responseURL).toBe(WINDOW_URL + REQUEST_URL); + }); + + it('Returns response URL for asynchrounous requests.', (done) => { + expect(request.responseURL).toBe(''); + + request.open('GET', REQUEST_URL, true); + + request.addEventListener('load', () => { + expect(request.responseURL).toBe(WINDOW_URL + REQUEST_URL); + done(); + }); + + request.send(); + }); + }); + describe('get readyState()', () => { it('Returns ready state for synchrounous requests.', () => { expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.unsent); - request.open('GET', URL, false); + request.open('GET', REQUEST_URL, false); request.send(); expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); @@ -33,7 +112,7 @@ describe('XMLHttpRequest', () => { expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.unsent); - request.open('GET', URL, true); + request.open('GET', REQUEST_URL, true); request.addEventListener('progress', () => { isProgressTriggered = true; @@ -52,18 +131,127 @@ describe('XMLHttpRequest', () => { describe('get responseText()', () => { it('Returns response text for synchrounous requests.', () => { - request.open('GET', URL, false); + request.open('GET', REQUEST_URL, false); request.send(); expect(request.responseText).toBe('child_process.execFileSync.returnValue.data.text'); }); it('Returns response text for asynchrounous requests.', (done) => { - request.open('GET', URL, true); + request.open('GET', REQUEST_URL, true); request.addEventListener('load', () => { expect(request.responseText).toBe('http.request.body'); done(); }); request.send(); }); + + it(`Throws an exception if responseType is not empty string or "${XMLHttpResponseTypeEnum.text}".`, () => { + request.responseType = XMLHttpResponseTypeEnum.json; + expect(() => request.responseText).toThrowError( + `Failed to read the 'responseText' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'text' (was '${XMLHttpResponseTypeEnum.json}').` + ); + }); + }); + + describe('set responseType()', () => { + it('Sets response type.', () => { + request.responseType = XMLHttpResponseTypeEnum.document; + expect(request.responseType).toBe(XMLHttpResponseTypeEnum.document); + }); + + it(`Throws an exception if readyState is "loading".`, (done) => { + request.open('GET', REQUEST_URL, true); + request.addEventListener('progress', () => { + expect(() => (request.responseType = XMLHttpResponseTypeEnum.json)).toThrowError( + `Failed to set the 'responseType' property on 'XMLHttpRequest': The object's state must be OPENED or UNSENT.` + ); + }); + request.addEventListener('load', () => done()); + request.send(); + }); + + it(`Throws an exception if the request is synchrounous.`, () => { + request.open('GET', REQUEST_URL, false); + expect(() => (request.responseType = XMLHttpResponseTypeEnum.json)).toThrowError( + `Failed to set the 'responseType' property on 'XMLHttpRequest': The response type cannot be changed for synchronous requests made from a document.` + ); + }); + }); + + describe('open()', () => { + it('Opens a request.', () => { + request.open('GET', REQUEST_URL, true); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.opened); + }); + + it('Throws an exception for forbidden request methods.', () => { + for (const forbiddenMethod of FORBIDDEN_REQUEST_METHODS) { + expect(() => request.open(forbiddenMethod, REQUEST_URL, true)).toThrowError( + 'Request method not allowed' + ); + } + }); + + it(`Throws an exception if the request is set to be synchronous and responseType is not ${XMLHttpResponseTypeEnum.text}.`, () => { + request.responseType = XMLHttpResponseTypeEnum.json; + expect(() => request.open('GET', REQUEST_URL, false)).toThrowError( + `Failed to execute 'open' on 'XMLHttpRequest': Synchronous requests from a document must not set a response type.` + ); + }); + }); + + describe('setRequestHeader()', () => { + it('Sets a request header on a synchronous request.', () => { + request.open('GET', REQUEST_URL, false); + request.setRequestHeader('test-header', 'test'); + request.send(); + + expect(mockedModules.modules.child_process.execFileSync.parameters.args[1]).toBe( + XMLHttpRequestSyncRequestScriptBuilder.getScript( + { + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'GET', + headers: { + accept: '*/*', + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + 'test-header': 'test', + host: window.location.host + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }, + true + ) + ); + }); + + it('Sets a request header on an asynchrounous request.', (done) => { + request.open('GET', REQUEST_URL, true); + request.setRequestHeader('test-header', 'test'); + request.addEventListener('load', () => { + expect(mockedModules.modules.http.request.parameters.options['headers']).toEqual({ + accept: '*/*', + cookie: '', + host: window.location.host, + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + 'test-header': 'test' + }); + done(); + }); + request.send(); + }); + + it(`Throws an exception if ready state is not "opened".`, () => { + expect(() => request.setRequestHeader('key', 'value')).toThrowError( + `Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED.` + ); + }); }); }); From ca00cb54303b00cc178c48c5b966714392abe94d Mon Sep 17 00:00:00 2001 From: David Ortner Date: Mon, 5 Dec 2022 22:15:50 +0100 Subject: [PATCH 35/40] #463@trivial: Continues on XMLHttpRequest implementation. --- .../src/xml-http-request/XMLHttpRequest.ts | 82 +++------ packages/happy-dom/test/setup.js | 2 +- .../xml-http-request/XMLHttpRequest.test.ts | 172 +++++++++++++++++- 3 files changed, 201 insertions(+), 55 deletions(-) diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 7d233c0b5..61186fc89 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -293,7 +293,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } if (this._state.send) { - throw new DOMException('send flag is true', DOMExceptionNameEnum.invalidStateError); + throw new DOMException( + `Failed to execute 'setRequestHeader' on 'XMLHttpRequest': Request is in progress.`, + DOMExceptionNameEnum.invalidStateError + ); } this._state.requestHeaders[lowerHeader] = value; @@ -310,8 +313,11 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { public getResponseHeader(header: string): string { const lowerHeader = header.toLowerCase(); + // Cookie headers are excluded for security reasons as per spec. if ( typeof header === 'string' && + header !== 'set-cookie' && + header !== 'set-cookie2' && this.readyState > XMLHttpRequestReadyStateEnum.opened && this._state.incommingMessage.headers[lowerHeader] && !this._state.error @@ -331,31 +337,17 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { if (this.readyState < XMLHttpRequestReadyStateEnum.headersRecieved || this._state.error) { return ''; } - let result = ''; + + const result = []; for (const name of Object.keys(this._state.incommingMessage.headers)) { - // Cookie headers are excluded + // Cookie headers are excluded for security reasons as per spec. if (name !== 'set-cookie' && name !== 'set-cookie2') { - result += `${name}: ${this._state.incommingMessage.headers[name]}\r\n`; + result.push(`${name}: ${this._state.incommingMessage.headers[name]}`); } } - return result.slice(0, -2); - } - - /** - * Gets a request header - * - * @param name Name of header to get. - * @returns Returns the request header or empty string if not set. - */ - public getRequestHeader(name: string): string { - const lowerName = name.toLowerCase(); - if (typeof name === 'string' && this._state.requestHeaders[lowerName]) { - return this._state.requestHeaders[lowerName]; - } - - return ''; + return result.join('\r\n'); } /** @@ -366,14 +358,14 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { public send(data?: string): void { if (this.readyState != XMLHttpRequestReadyStateEnum.opened) { throw new DOMException( - 'connection must be opened before send() is called', + `Failed to execute 'send' on 'XMLHttpRequest': Connection must be opened before send() is called.`, DOMExceptionNameEnum.invalidStateError ); } if (this._state.send) { throw new DOMException( - 'send has already been called', + `Failed to execute 'send' on 'XMLHttpRequest': Send has already been called.`, DOMExceptionNameEnum.invalidStateError ); } @@ -392,8 +384,22 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Load files off the local filesystem (file://) if (XMLHttpRequestURLUtility.isLocal(url)) { + if (!this._ownerDocument.defaultView.happyDOM.settings.enableFileSystemHttpRequests) { + throw new DOMException( + 'File system is disabled by default for security reasons. To enable it, set the "window.happyDOM.settings.enableFileSystemHttpRequests" option to true.', + DOMExceptionNameEnum.securityError + ); + } + + if (this._settings.method !== 'GET') { + throw new DOMException( + 'Failed to send local file system request. Only "GET" method is supported for local file system requests.', + DOMExceptionNameEnum.notSupportedError + ); + } + if (this._settings.async) { - this._sendLocalAsyncRequest(url); + this._sendLocalAsyncRequest(url).catch((error) => this._onError(error)); } else { this._sendLocalSyncRequest(url); } @@ -458,7 +464,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // Handle async requests if (this._settings.async) { - this._sendAsyncRequest(options, ssl, data); + this._sendAsyncRequest(options, ssl, data).catch((error) => this._onError(error)); } else { this._sendSyncRequest(options, ssl, data); } @@ -809,20 +815,6 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this.abort.bind(this) ); - if (!this._ownerDocument.defaultView.happyDOM.settings.enableFileSystemHttpRequests) { - throw new DOMException( - 'File system is disabled by default for security reasons. To enable it, set the "window.happyDOM.settings.enableFileSystemHttpRequests" option to true.', - DOMExceptionNameEnum.securityError - ); - } - - if (this._settings.method !== 'GET') { - throw new DOMException( - 'Only GET method is supported', - DOMExceptionNameEnum.notSupportedError - ); - } - let data: Buffer; try { @@ -844,20 +836,6 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @param url URL. */ private _sendLocalSyncRequest(url: UrlObject): void { - if (!this._ownerDocument.defaultView.happyDOM.settings.enableFileSystemHttpRequests) { - throw new DOMException( - 'File system is disabled by default for security reasons. To enable it, set the "window.happyDOM.settings.enableFileSystemHttpRequests" option to true.', - DOMExceptionNameEnum.securityError - ); - } - - if (this._settings.method !== 'GET') { - throw new DOMException( - 'Only GET method is supported', - DOMExceptionNameEnum.notSupportedError - ); - } - let data: Buffer; try { data = FS.readFileSync(decodeURI(url.pathname.slice(1))); diff --git a/packages/happy-dom/test/setup.js b/packages/happy-dom/test/setup.js index 0f642a494..90c90c319 100644 --- a/packages/happy-dom/test/setup.js +++ b/packages/happy-dom/test/setup.js @@ -67,7 +67,7 @@ const modules = { }, returnValue: { response: { - headers: 'http.request.headers', + headers: { key1: 'value1', key2: 'value2' }, statusCode: 200, statusMessage: 'http.request.statusMessage', body: 'http.request.body', diff --git a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts index 307e16614..cb6b346b9 100644 --- a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts +++ b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts @@ -9,6 +9,28 @@ import XMLHttpRequestCertificate from '../../src/xml-http-request/XMLHttpRequest const WINDOW_URL = 'https://localhost:8080'; const REQUEST_URL = '/path/to/resource/'; const FORBIDDEN_REQUEST_METHODS = ['TRACE', 'TRACK', 'CONNECT']; +const FORBIDDEN_REQUEST_HEADERS = [ + 'accept-charset', + 'accept-encoding', + 'access-control-request-headers', + 'access-control-request-method', + 'connection', + 'content-length', + 'content-transfer-encoding', + 'cookie', + 'cookie2', + 'date', + 'expect', + 'host', + 'keep-alive', + 'origin', + 'referer', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'via' +]; describe('XMLHttpRequest', () => { let window: IWindow; @@ -203,7 +225,7 @@ describe('XMLHttpRequest', () => { describe('setRequestHeader()', () => { it('Sets a request header on a synchronous request.', () => { request.open('GET', REQUEST_URL, false); - request.setRequestHeader('test-header', 'test'); + expect(request.setRequestHeader('test-header', 'test')).toBe(true); request.send(); expect(mockedModules.modules.child_process.execFileSync.parameters.args[1]).toBe( @@ -233,7 +255,7 @@ describe('XMLHttpRequest', () => { it('Sets a request header on an asynchrounous request.', (done) => { request.open('GET', REQUEST_URL, true); - request.setRequestHeader('test-header', 'test'); + expect(request.setRequestHeader('test-header', 'test')).toBe(true); request.addEventListener('load', () => { expect(mockedModules.modules.http.request.parameters.options['headers']).toEqual({ accept: '*/*', @@ -248,10 +270,156 @@ describe('XMLHttpRequest', () => { request.send(); }); + it('Does not set forbidden headers.', () => { + request.open('GET', REQUEST_URL, true); + for (const header of FORBIDDEN_REQUEST_HEADERS) { + expect(request.setRequestHeader(header, 'test')).toBe(false); + } + }); + it(`Throws an exception if ready state is not "opened".`, () => { expect(() => request.setRequestHeader('key', 'value')).toThrowError( `Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED.` ); }); }); + + describe('getResponseHeader()', () => { + it('Returns response header for a synchrounous request.', () => { + mockedModules.modules.child_process.execFileSync.returnValue.data.headers['set-cookie'] = + 'cookie'; + mockedModules.modules.child_process.execFileSync.returnValue.data.headers['set-cookie2'] = + 'cookie'; + + request.open('GET', REQUEST_URL, false); + request.send(); + + expect(request.getResponseHeader('key1')).toBe('value1'); + expect(request.getResponseHeader('key2')).toBe('value2'); + expect(request.getResponseHeader('key3')).toBe(null); + + // These cookies should always be null for security reasons. + expect(request.getResponseHeader('set-cookie')).toBe(null); + expect(request.getResponseHeader('set-cookie2')).toBe(null); + }); + + it('Returns response header for an asynchrounous request.', (done) => { + mockedModules.modules.http.request.returnValue.response.headers['set-cookie'] = 'cookie'; + mockedModules.modules.http.request.returnValue.response.headers['set-cookie2'] = 'cookie'; + + request.open('GET', REQUEST_URL, false); + + request.addEventListener('load', () => { + expect(request.getResponseHeader('key1')).toBe('value1'); + expect(request.getResponseHeader('key2')).toBe('value2'); + expect(request.getResponseHeader('key3')).toBe(null); + + // These cookies should always be null for security reasons. + expect(request.getResponseHeader('set-cookie')).toBe(null); + expect(request.getResponseHeader('set-cookie2')).toBe(null); + + done(); + }); + + request.send(); + }); + + it('Returns null when there is no response.', () => { + expect(request.getResponseHeader('key1')).toBe(null); + }); + }); + + describe('getAllResponseHeaders()', () => { + it('Returns all response headers for a synchrounous request.', () => { + mockedModules.modules.child_process.execFileSync.returnValue.data.headers['set-cookie'] = + 'cookie'; + mockedModules.modules.child_process.execFileSync.returnValue.data.headers['set-cookie2'] = + 'cookie'; + + request.open('GET', REQUEST_URL, false); + request.send(); + + expect(request.getAllResponseHeaders()).toBe('key1: value1\r\nkey2: value2'); + }); + + it('Returns all response headers for an asynchrounous request.', (done) => { + mockedModules.modules.http.request.returnValue.response.headers['set-cookie'] = 'cookie'; + mockedModules.modules.http.request.returnValue.response.headers['set-cookie2'] = 'cookie'; + + request.open('GET', REQUEST_URL, false); + + request.addEventListener('load', () => { + expect(request.getAllResponseHeaders()).toBe('key1: value1\r\nkey2: value2'); + done(); + }); + + request.send(); + }); + + it('Returns empty string when there is no response.', () => { + expect(request.getAllResponseHeaders()).toBe(''); + }); + }); + + describe('send()', () => { + it('Throws an exception if the request has not been opened.', () => { + expect(() => request.send()).toThrowError( + `Failed to execute 'send' on 'XMLHttpRequest': Connection must be opened before send() is called.` + ); + }); + + it('Throws an exception if the request has already been sent.', () => { + request.open('GET', REQUEST_URL, true); + request.send(); + + expect(() => request.send()).toThrowError( + `Failed to execute 'send' on 'XMLHttpRequest': Send has already been called.` + ); + }); + + it('Throws an exception when the page is HTTPS and the request is HTTP.', () => { + const unsecureURL = 'http://unsecure.happydom'; + request.open('GET', unsecureURL, false); + + expect(() => request.send()).toThrowError( + `Mixed Content: The page at '${window.location.href}' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint '${unsecureURL}/'. This request has been blocked; the content must be served over HTTPS.` + ); + }); + + it('Throws an exception when doing a synchronous request towards a local file if "window.happyDOM.settings.enableFileSystemHttpRequests" has not been enabled.', () => { + request.open('GET', 'file://C:/path/to/file.txt', false); + + expect(() => request.send()).toThrowError( + 'File system is disabled by default for security reasons. To enable it, set the "window.happyDOM.settings.enableFileSystemHttpRequests" option to true.' + ); + }); + + it('Throws an exception when doing an asynchronous request towards a local file if "window.happyDOM.settings.enableFileSystemHttpRequests" has not been enabled.', () => { + request.open('GET', 'file://C:/path/to/file.txt', true); + + expect(() => request.send()).toThrowError( + 'File system is disabled by default for security reasons. To enable it, set the "window.happyDOM.settings.enableFileSystemHttpRequests" option to true.' + ); + }); + + it('Throws an exception when doing a synchronous request towards a local file with another method than "GET".', () => { + window.happyDOM.settings.enableFileSystemHttpRequests = true; + + request.open('POST', 'file://C:/path/to/file.txt', false); + + expect(() => request.send()).toThrowError( + 'Failed to send local file system request. Only "GET" method is supported for local file system requests.' + ); + }); + + it('Throws an exception when doing a asynchronous request towards a local file with another method than "GET".', () => { + window.happyDOM.settings.enableFileSystemHttpRequests = true; + + request.open('POST', 'file://C:/path/to/file.txt', true); + + expect(() => request.send()).toThrowError( + 'Failed to send local file system request. Only "GET" method is supported for local file system requests.' + ); + }); + }); }); From 96682af53ee6c5eb33194be92495f0df8921db17 Mon Sep 17 00:00:00 2001 From: Mason Shi <60805843+Mas0nShi@users.noreply.github.com> Date: Tue, 6 Dec 2022 06:09:04 +0000 Subject: [PATCH 36/40] #520@patch: Fixes cookie setting. --- .../src/xml-http-request/XMLHttpRequest.ts | 59 ++++++++++++------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 61186fc89..a2b78c39e 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -430,21 +430,24 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._state.requestHeaders['authorization'] = 'Basic ' + authBuffer.toString('base64'); } // Set the Content-Length header if method is POST - if (this._settings.method === 'GET' || this._settings.method === 'HEAD') { - data = null; - } else if (this._settings.method === 'POST') { - // Set default content type if not set. - if (!this._state.requestHeaders['content-type']) { - this._state.requestHeaders['content-type'] = 'text/plain;charset=UTF-8'; - } + switch (this._settings.method) { + case 'GET': + case 'HEAD': + data = null; + break; + case 'POST': + this._state.requestHeaders['content-type'] ??= 'text/plain;charset=UTF-8'; + if (data) { + this._state.requestHeaders['content-length'] = Buffer.isBuffer(data) + ? data.length + : Buffer.byteLength(data); + } else { + this._state.requestHeaders['content-length'] = 0; + } + break; - if (data) { - this._state.requestHeaders['content-length'] = Buffer.isBuffer(data) - ? data.length - : Buffer.byteLength(data); - } else { - this._state.requestHeaders['content-length'] = 0; - } + default: + break; } const options: HTTPS.RequestOptions = { @@ -547,11 +550,13 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { * @returns Default request headers. */ private _getDefaultRequestHeaders(): { [key: string]: string } { + const { location, navigator, document } = this._ownerDocument.defaultView; + return { accept: '*/*', - referer: this._ownerDocument.defaultView.location.href, - 'user-agent': this._ownerDocument.defaultView.navigator.userAgent, - cookie: this._ownerDocument.defaultView.document.cookie + referer: location.href, + 'user-agent': navigator.userAgent, + cookie: document._cookie.getCookiesString(location, false) }; } @@ -597,11 +602,7 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._settings.url ).href; // Set Cookies. - if (this._state.incommingMessage.headers['set-cookie']) { - // TODO: Bugs in CookieJar. - this._ownerDocument.defaultView.document.cookie = - this._state.incommingMessage.headers['set-cookie']; - } + this._setCookies(this._state.incommingMessage.headers); // Redirect. if ( this._state.incommingMessage.statusCode === 301 || @@ -709,6 +710,9 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { // This is so it remains accessable outside this scope this._state.incommingMessage = response; + // Set Cookies + this._setCookies(this._state.incommingMessage.headers); + // Check for redirect // @TODO Prevent looped redirects if ( @@ -950,6 +954,17 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { } } + /** + * Set Cookies from response headers. + * + * @param headers String array. + */ + private _setCookies(headers: string[] | HTTP.IncomingHttpHeaders): void { + for (const cookie of [...(headers['set-cookie'] ?? []), ...(headers['set-cookie2'] ?? [])]) { + this._ownerDocument.defaultView.document._cookie.setCookiesString(cookie); + } + } + /** * Called when an error is encountered to deal with it. * From b670ae67c5460fd56decb239f573710b28bcd130 Mon Sep 17 00:00:00 2001 From: David Ortner Date: Wed, 7 Dec 2022 00:38:50 +0100 Subject: [PATCH 37/40] #463@trivial: Continues on XMLHttpRequest implementation. --- packages/happy-dom/README.md | 64 ++- .../async-task-manager/AsyncTaskManager.ts | 6 - packages/happy-dom/src/window/IWindow.ts | 1 + packages/happy-dom/src/window/Window.ts | 3 + .../src/xml-http-request/XMLHttpRequest.ts | 37 +- .../XMLHttpRequestSyncRequestScriptBuilder.ts | 13 +- packages/happy-dom/test/setup.js | 14 +- packages/happy-dom/test/types.d.ts | 3 + .../xml-http-request/XMLHttpRequest.test.ts | 472 +++++++++++++++++- 9 files changed, 548 insertions(+), 65 deletions(-) diff --git a/packages/happy-dom/README.md b/packages/happy-dom/README.md index d192a1da5..af769ea46 100644 --- a/packages/happy-dom/README.md +++ b/packages/happy-dom/README.md @@ -1,6 +1,5 @@ ![Happy DOM Logo](https://github.com/capricorn86/happy-dom/raw/master/docs/happy-dom-logo.jpg) - # About [Happy DOM](https://github.com/capricorn86/happy-dom) is a JavaScript implementation of a web browser without its graphical user interface. It includes many web standards from WHATWG [DOM](https://dom.spec.whatwg.org/) and [HTML](https://html.spec.whatwg.org/multipage/). @@ -9,7 +8,6 @@ The goal of [Happy DOM](https://github.com/capricorn86/happy-dom) is to emulate [Happy DOM](https://github.com/capricorn86/happy-dom) focuses heavily on performance and can be used as an alternative to [JSDOM](https://github.com/jsdom/jsdom). - ### DOM Features - Custom Elements (Web Components) @@ -26,8 +24,6 @@ The goal of [Happy DOM](https://github.com/capricorn86/happy-dom) is to emulate And much more.. - - ### Works With - [Google LitHTML](https://lit-html.polymer-project.org) @@ -40,20 +36,14 @@ And much more.. - [Vue](https://vuejs.org/) - - # Installation ```bash npm install happy-dom ``` - - # Usage - - ## Basic Usage A simple example of how you can use Happy DOM. @@ -75,8 +65,6 @@ container.appendChild(button); console.log(document.body.innerHTML); ``` - - ## VM Context The default Window class is a [VM context](https://nodejs.org/api/vm.html#vm_vm_createcontext_sandbox_options). A [VM context](https://nodejs.org/api/vm.html#vm_vm_createcontext_sandbox_options) will execute JavaScript code scoped within the context where the Window instance will be the global object. @@ -85,9 +73,9 @@ The default Window class is a [VM context](https://nodejs.org/api/vm.html#vm_vm_ import { Window } from 'happy-dom'; const window = new Window({ - innerWidth: 1024, - innerHeight: 768, - url: 'http://localhost:8080' + innerWidth: 1024, + innerHeight: 768, + url: 'http://localhost:8080' }); const document = window.document; @@ -146,9 +134,9 @@ The example below will show you how to setup a Node [VM context](https://nodejs. import { Window } from 'happy-dom'; const window = new Window({ - innerWidth: 1024, - innerHeight: 768, - url: 'http://localhost:8080' + innerWidth: 1024, + innerHeight: 768, + url: 'http://localhost:8080' }); const document = window.document; @@ -207,8 +195,6 @@ Will output: console.log(document.body.querySelector('div').getInnerHTML({ includeShadowRoots: true })); ``` - - ## Additional Features **whenAsyncComplete()** @@ -217,7 +203,7 @@ Returns a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe ```javascript window.happyDOM.whenAsyncComplete().then(() => { - // Do something when all async tasks are completed. + // Do something when all async tasks are completed. }); ``` @@ -227,7 +213,7 @@ This method will cancel all running async tasks. ```javascript window.setTimeout(() => { - // This timeout will be canceled + // This timeout will be canceled }); window.happyDOM.cancelAsync(); ``` @@ -248,26 +234,36 @@ Sets the property `window.innerHeight` and dispatches a "resize" event. window.happyDOM.setInnerHeight(768); ``` +**setURL()** + +Sets the property `window.location.href`. + +```javascript +window.happyDOM.setURL('https://localhost:3000'); +``` + ## Settings Settings can be sent to the constructor or by setting them on the "window.happyDOM.settings" property. Set by constructor: + ```javascript const window = new Window({ - innerWidth: 1024, - innerHeight: 768, - url: 'http://localhost:8080', - settings: { - disableJavaScriptFileLoading: true, - disableJavaScriptEvaluation: true, - disableCSSFileLoading: true, - enableFileSystemHttpRequests: true - } + innerWidth: 1024, + innerHeight: 768, + url: 'https://localhost:8080', + settings: { + disableJavaScriptFileLoading: true, + disableJavaScriptEvaluation: true, + disableCSSFileLoading: true, + enableFileSystemHttpRequests: true + } }); ``` Set by property: + ```javascript const window = new Window(); @@ -293,8 +289,6 @@ Set it to "true" to disable CSS file loading using the HTMLLinkElement. Defaults Set it to "true" to enable file system HTTP requests using XMLHttpRequest. Defaults to "false". - - # Performance | Operation | JSDOM | Happy DOM | @@ -311,12 +305,10 @@ Set it to "true" to enable file system HTTP requests using XMLHttpRequest. Defau [See how the test was done here](https://github.com/capricorn86/happy-dom-performance-test) - - # Jest Happy DOM provide with a package called [@happy-dom/jest-environment](https://github.com/capricorn86/happy-dom/tree/master/packages/jest-environment) that makes it possible to use Happy DOM with [Jest](https://jestjs.io/). # Global Registration -Happy DOM provide with a package called [@happy-dom/global-registrator](https://github.com/capricorn86/happy-dom/tree/master/packages/global-registrator) that can register Happy DOM globally. It makes it possible to use Happy DOM for testing in a Node environment. \ No newline at end of file +Happy DOM provide with a package called [@happy-dom/global-registrator](https://github.com/capricorn86/happy-dom/tree/master/packages/global-registrator) that can register Happy DOM globally. It makes it possible to use Happy DOM for testing in a Node environment. diff --git a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts index a853a2ea4..b87ae01cd 100644 --- a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts +++ b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts @@ -101,12 +101,6 @@ export default class AsyncTaskManager { */ public endTask(taskID: number): void { if (this.runningTasks[taskID]) { - try { - this.runningTasks[taskID](); - } catch (error) { - this.cancelAll(error); - return; - } delete this.runningTasks[taskID]; if (!Object.keys(this.runningTasks).length && !this.runningTimers.length) { diff --git a/packages/happy-dom/src/window/IWindow.ts b/packages/happy-dom/src/window/IWindow.ts index 51de85610..306b88cda 100644 --- a/packages/happy-dom/src/window/IWindow.ts +++ b/packages/happy-dom/src/window/IWindow.ts @@ -115,6 +115,7 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { asyncTaskManager: AsyncTaskManager; setInnerWidth: (width: number) => void; setInnerHeight: (height: number) => void; + setURL: (url: string) => void; settings: IHappyDOMSettings; }; diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index 0a68d851a..0b1442d97 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -148,6 +148,9 @@ export default class Window extends EventTarget implements IWindow { this.dispatchEvent(new Event('resize')); } }, + setURL: (url: string) => { + this.location.href = url; + }, settings: { disableJavaScriptEvaluation: false, disableJavaScriptFileLoading: false, diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index a2b78c39e..24a402913 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -581,21 +581,22 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { throw new DOMException('Synchronous request failed', DOMExceptionNameEnum.networkError); } - const { err: error, data: responseObj } = JSON.parse(content.toString()); + const { error, data: response } = JSON.parse(content.toString()); + if (error) { - this._onError(error, 503); + this._onError(error); } - if (responseObj) { + if (response) { this._state.incommingMessage = { - statusCode: responseObj.statusCode, - headers: responseObj.headers + statusCode: response.statusCode, + headers: response.headers }; - this._state.status = responseObj.statusCode; - this._state.statusText = responseObj.statusMessage; + this._state.status = response.statusCode; + this._state.statusText = response.statusMessage; // Sync responseType === '' - this._state.response = responseObj.text; - this._state.responseText = responseObj.text; + this._state.response = response.text; + this._state.responseText = response.text; this._state.responseXML = null; this._state.responseURL = RelativeURL.getAbsoluteURL( this._ownerDocument.defaultView.location, @@ -827,6 +828,17 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._onError(error); } + const dataLength = data.length; + + this._setState(XMLHttpRequestReadyStateEnum.loading); + this.dispatchEvent( + new ProgressEvent('progress', { + lengthComputable: true, + loaded: dataLength, + total: dataLength + }) + ); + if (data) { this._parseLocalRequestData(data); } @@ -968,11 +980,10 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { /** * Called when an error is encountered to deal with it. * - * @param error Error object. - * @param status HTTP status code to use rather than the default (0) for XHR errors. + * @param error Error. */ - private _onError(error: Error | string, status = 0): void { - this._state.status = status; + private _onError(error: Error | string): void { + this._state.status = 0; this._state.statusText = error.toString(); this._state.responseText = error instanceof Error ? error.stack : ''; this._state.error = true; diff --git a/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder.ts b/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder.ts index 3b0a92f24..9e8910a1e 100644 --- a/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder.ts +++ b/packages/happy-dom/src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder.ts @@ -29,10 +29,19 @@ export default class XMLHttpRequestSyncRequestScriptBuilder { responseData = Buffer.concat([responseData, Buffer.from(chunk)]); }); response.on('end', () => { - console.log(JSON.stringify({err: null, data: {statusCode: response.statusCode, statusMessage: response.statusMessage, headers: response.headers, text: responseText, data: responseData.toString('base64')}})); + console.log(JSON.stringify({ + error: null, + data: { + statusCode: response.statusCode, + statusMessage: response.statusMessage, + headers: response.headers, + text: responseText, + data: responseData.toString('base64') + } + })); }); response.on('error', (error) => { - console.log(JSON.stringify({err: error, data: null})); + console.log(JSON.stringify({ error: error.toString(), data: null })); }); }); request.write(\`${JSON.stringify(data ?? '') diff --git a/packages/happy-dom/test/setup.js b/packages/happy-dom/test/setup.js index 90c90c319..e9b98fe53 100644 --- a/packages/happy-dom/test/setup.js +++ b/packages/happy-dom/test/setup.js @@ -49,7 +49,7 @@ const modules = { data: { statusCode: 200, statusMessage: 'child_process.execFileSync.returnValue.data.statusMessage', - headers: { key1: 'value1', key2: 'value2' }, + headers: { key1: 'value1', key2: 'value2', 'content-length': '48' }, text: 'child_process.execFileSync.returnValue.data.text', data: Buffer.from('child_process.execFileSync.returnValue.data.text').toString('base64') }, @@ -63,11 +63,12 @@ const modules = { options: null }, internal: { - body: null + body: '', + destroyed: false }, returnValue: { response: { - headers: { key1: 'value1', key2: 'value2' }, + headers: { key1: 'value1', key2: 'value2', 'content-length': '17' }, statusCode: 200, statusMessage: 'http.request.statusMessage', body: 'http.request.body', @@ -203,9 +204,9 @@ const httpMock = () => { callback(response); - if (global.mockedModules.modules.http.request.returnValue.error) { + if (global.mockedModules.modules.http.request.returnValue.response.error) { for (const listener of response._eventListeners.error) { - listener(global.mockedModules.modules.http.request.returnValue.error); + listener(global.mockedModules.modules.http.request.returnValue.response.error); } } else { for (const listener of response._eventListeners.data) { @@ -223,6 +224,9 @@ const httpMock = () => { errorCallback = callback; } return request; + }, + destroy: () => { + global.mockedModules.modules.http.request.internal.destroyed = true; } }; return request; diff --git a/packages/happy-dom/test/types.d.ts b/packages/happy-dom/test/types.d.ts index a0794f4c0..4a383c43d 100644 --- a/packages/happy-dom/test/types.d.ts +++ b/packages/happy-dom/test/types.d.ts @@ -49,10 +49,12 @@ declare let mockedModules: { returnValue: { data: { statusCode: number; + statusMessage: string; headers: { [k: string]: string }; text: string; data: string; }; + error: string; }; }; }; @@ -63,6 +65,7 @@ declare let mockedModules: { }; internal: { body: string; + destroyed: boolean; }; returnValue: { response: { diff --git a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts index cb6b346b9..00f89dfb8 100644 --- a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts +++ b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts @@ -5,6 +5,7 @@ import XMLHttpRequestReadyStateEnum from '../../src/xml-http-request/XMLHttpRequ import XMLHttpResponseTypeEnum from '../../src/xml-http-request/XMLHttpResponseTypeEnum'; import XMLHttpRequestSyncRequestScriptBuilder from '../../src/xml-http-request/utilities/XMLHttpRequestSyncRequestScriptBuilder'; import XMLHttpRequestCertificate from '../../src/xml-http-request/XMLHttpRequestCertificate'; +import { ProgressEvent } from 'src'; const WINDOW_URL = 'https://localhost:8080'; const REQUEST_URL = '/path/to/resource/'; @@ -339,17 +340,21 @@ describe('XMLHttpRequest', () => { request.open('GET', REQUEST_URL, false); request.send(); - expect(request.getAllResponseHeaders()).toBe('key1: value1\r\nkey2: value2'); + expect(request.getAllResponseHeaders()).toBe( + 'key1: value1\r\nkey2: value2\r\ncontent-length: 48' + ); }); it('Returns all response headers for an asynchrounous request.', (done) => { mockedModules.modules.http.request.returnValue.response.headers['set-cookie'] = 'cookie'; mockedModules.modules.http.request.returnValue.response.headers['set-cookie2'] = 'cookie'; - request.open('GET', REQUEST_URL, false); + request.open('GET', REQUEST_URL, true); request.addEventListener('load', () => { - expect(request.getAllResponseHeaders()).toBe('key1: value1\r\nkey2: value2'); + expect(request.getAllResponseHeaders()).toBe( + 'key1: value1\r\nkey2: value2\r\ncontent-length: 17' + ); done(); }); @@ -421,5 +426,466 @@ describe('XMLHttpRequest', () => { 'Failed to send local file system request. Only "GET" method is supported for local file system requests.' ); }); + + it('Performs a synchronous request towards a local file.', () => { + window.happyDOM.settings.enableFileSystemHttpRequests = true; + + request.open('GET', 'file://C:/path/to/file.txt', false); + + request.send(); + + expect(mockedModules.modules.fs.readFileSync.parameters.path).toBe('C:/path/to/file.txt'); + expect(request.responseText).toBe('fs.readFileSync'); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + }); + + it('Performs an asynchronous request towards a local file.', (done) => { + const expectedResponseText = 'fs.promises.readFile'; + window.happyDOM.settings.enableFileSystemHttpRequests = true; + + request.open('GET', 'file://C:/path/to/file.txt', true); + + let isProgressTriggered = false; + + request.addEventListener('progress', (event: ProgressEvent) => { + isProgressTriggered = true; + expect(event.lengthComputable).toBe(true); + expect(event.loaded).toBe(expectedResponseText.length); + expect(event.total).toBe(expectedResponseText.length); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); + }); + + request.addEventListener('load', () => { + expect(mockedModules.modules.fs.promises.readFile.parameters.path).toBe( + 'C:/path/to/file.txt' + ); + expect(request.responseText).toBe(expectedResponseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + expect(isProgressTriggered).toBe(true); + done(); + }); + + request.send(); + }); + + it('Performs a synchronous GET request with the HTTP protocol.', () => { + const windowURL = 'http://localhost:8080'; + + window.happyDOM.setURL(windowURL); + + request.open('GET', REQUEST_URL, false); + + request.send(); + + expect( + mockedModules.modules.child_process.execFileSync.parameters.command.endsWith('node') + ).toBe(true); + + expect(mockedModules.modules.child_process.execFileSync.parameters.options).toEqual({ + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 1024 + }); + + expect(mockedModules.modules.child_process.execFileSync.parameters.args[1]).toBe( + XMLHttpRequestSyncRequestScriptBuilder.getScript( + { + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'GET', + headers: { + accept: '*/*', + referer: windowURL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host + }, + agent: false, + rejectUnauthorized: true, + key: null, + cert: null + }, + false + ) + ); + + expect(request.responseText).toBe('child_process.execFileSync.returnValue.data.text'); + }); + + it('Performs a synchronous GET request with the HTTPS protocol.', () => { + request.open('GET', REQUEST_URL, false); + + request.send(); + + expect( + mockedModules.modules.child_process.execFileSync.parameters.command.endsWith('node') + ).toBe(true); + + expect(mockedModules.modules.child_process.execFileSync.parameters.options).toEqual({ + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 1024 + }); + + expect(mockedModules.modules.child_process.execFileSync.parameters.args[1]).toBe( + XMLHttpRequestSyncRequestScriptBuilder.getScript( + { + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'GET', + headers: { + accept: '*/*', + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }, + true + ) + ); + + expect(request.responseText).toBe('child_process.execFileSync.returnValue.data.text'); + }); + + it('Performs an asynchronous GET request with the HTTP protocol.', (done) => { + const expectedResponseText = 'http.request.body'; + const windowURL = 'http://localhost:8080'; + + window.location.href = windowURL; + + request.open('GET', REQUEST_URL, true); + + let isProgressTriggered = false; + + request.addEventListener('progress', (event: ProgressEvent) => { + isProgressTriggered = true; + expect(event.lengthComputable).toBe(true); + expect(event.loaded).toBe(expectedResponseText.length); + expect(event.total).toBe(expectedResponseText.length); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); + }); + + request.addEventListener('load', () => { + expect(mockedModules.modules.http.request.parameters.options).toEqual({ + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'GET', + headers: { + accept: '*/*', + referer: windowURL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host + }, + agent: false, + rejectUnauthorized: true, + key: null, + cert: null + }); + expect(mockedModules.modules.http.request.internal.body).toBe(''); + expect(request.responseText).toBe(expectedResponseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + expect(isProgressTriggered).toBe(true); + done(); + }); + + request.send(); + }); + + it('Performs an asynchronous GET request with the HTTPS protocol and query string.', (done) => { + const expectedResponseText = 'http.request.body'; + const queryString = '?query=string'; + + request.open('GET', REQUEST_URL + queryString, true); + + let isProgressTriggered = false; + + request.addEventListener('progress', (event: ProgressEvent) => { + isProgressTriggered = true; + expect(event.lengthComputable).toBe(true); + expect(event.loaded).toBe(expectedResponseText.length); + expect(event.total).toBe(expectedResponseText.length); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); + }); + + request.addEventListener('load', () => { + expect(mockedModules.modules.http.request.parameters.options).toEqual({ + host: 'localhost', + port: 8080, + path: REQUEST_URL + queryString, + method: 'GET', + headers: { + accept: '*/*', + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }); + expect(mockedModules.modules.http.request.internal.body).toBe(''); + expect(request.responseText).toBe(expectedResponseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + expect(isProgressTriggered).toBe(true); + done(); + }); + + request.send(); + }); + + it('Handles responses without content length.', (done) => { + const expectedResponseText = 'http.request.body'; + + delete mockedModules.modules.http.request.returnValue.response.headers['content-length']; + + request.open('GET', REQUEST_URL, true); + + request.addEventListener('progress', (event: ProgressEvent) => { + expect(event.lengthComputable).toBe(false); + expect(event.loaded).toBe(expectedResponseText.length); + expect(event.total).toBe(0); + done(); + }); + + request.send(); + }); + + it('Performs an asynchronous GET request with the HTTPS protocol.', (done) => { + const expectedResponseText = 'http.request.body'; + + request.open('GET', REQUEST_URL, true); + + let isProgressTriggered = false; + + request.addEventListener('progress', (event: ProgressEvent) => { + isProgressTriggered = true; + expect(event.lengthComputable).toBe(true); + expect(event.loaded).toBe(expectedResponseText.length); + expect(event.total).toBe(expectedResponseText.length); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); + }); + + request.addEventListener('load', () => { + expect(mockedModules.modules.http.request.parameters.options).toEqual({ + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'GET', + headers: { + accept: '*/*', + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }); + expect(mockedModules.modules.http.request.internal.body).toBe(''); + expect(request.responseText).toBe(expectedResponseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + expect(isProgressTriggered).toBe(true); + done(); + }); + + request.send(); + }); + + it('Performs an asynchronous basic auth request with username and password.', (done) => { + const username = 'username'; + const password = 'password'; + const expectedResponseText = 'http.request.body'; + + request.open('GET', REQUEST_URL, true, username, password); + + let isProgressTriggered = false; + + request.addEventListener('progress', (event: ProgressEvent) => { + isProgressTriggered = true; + expect(event.lengthComputable).toBe(true); + expect(event.loaded).toBe(expectedResponseText.length); + expect(event.total).toBe(expectedResponseText.length); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); + }); + + request.addEventListener('load', () => { + expect(mockedModules.modules.http.request.parameters.options).toEqual({ + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'GET', + headers: { + accept: '*/*', + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host, + authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }); + expect(mockedModules.modules.http.request.internal.body).toBe(''); + expect(request.responseText).toBe(expectedResponseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + expect(isProgressTriggered).toBe(true); + done(); + }); + + request.send(); + }); + + it('Performs an asynchronous basic auth request with only username.', (done) => { + const username = 'username'; + const expectedResponseText = 'http.request.body'; + + request.open('GET', REQUEST_URL, true, username); + + request.addEventListener('load', () => { + expect(mockedModules.modules.http.request.parameters.options).toEqual({ + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'GET', + headers: { + accept: '*/*', + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host, + authorization: `Basic ${Buffer.from(`${username}:`).toString('base64')}` + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }); + expect(mockedModules.modules.http.request.internal.body).toBe(''); + expect(request.responseText).toBe(expectedResponseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + done(); + }); + + request.send(); + }); + + it('Performs an asynchronous POST request.', (done) => { + const postData = 'post.data'; + const expectedResponseText = 'http.request.body'; + + request.open('POST', REQUEST_URL, true); + + let isProgressTriggered = false; + + request.addEventListener('progress', (event: ProgressEvent) => { + isProgressTriggered = true; + expect(event.lengthComputable).toBe(true); + expect(event.loaded).toBe(expectedResponseText.length); + expect(event.total).toBe(expectedResponseText.length); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); + }); + + request.addEventListener('load', () => { + expect(mockedModules.modules.http.request.parameters.options).toEqual({ + host: 'localhost', + port: 8080, + path: REQUEST_URL, + method: 'POST', + headers: { + accept: '*/*', + 'content-length': postData.length, + 'content-type': 'text/plain;charset=UTF-8', + referer: WINDOW_URL + '/', + 'user-agent': window.navigator.userAgent, + cookie: '', + host: window.location.host + }, + agent: false, + rejectUnauthorized: true, + key: XMLHttpRequestCertificate.key, + cert: XMLHttpRequestCertificate.cert + }); + expect(mockedModules.modules.http.request.internal.body).toBe(postData); + expect(request.responseText).toBe(expectedResponseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + expect(isProgressTriggered).toBe(true); + done(); + }); + + request.send(postData); + }); + + it('Handles error in request when performing an asynchronous request.', (done) => { + mockedModules.modules.http.request.returnValue.request.error = new Error('error'); + + request.open('GET', REQUEST_URL, true); + + request.addEventListener('load', () => { + throw new Error('Load event should not be triggered.'); + }); + + request.addEventListener('error', () => { + expect(request.status).toBe(0); + expect(request.statusText).toBe('Error: error'); + expect( + request.responseText.startsWith('Error: error') && request.responseText.includes(' at ') + ).toBe(true); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + done(); + }); + + request.send(); + }); + + it('Handles error in response when performing an asynchronous request.', (done) => { + mockedModules.modules.http.request.returnValue.response.error = new Error('error'); + + request.open('GET', REQUEST_URL, true); + + request.addEventListener('load', () => { + throw new Error('Load event should not be triggered.'); + }); + + request.addEventListener('error', () => { + expect(request.status).toBe(0); + expect(request.statusText).toBe('Error: error'); + expect( + request.responseText.startsWith('Error: error') && request.responseText.includes(' at ') + ).toBe(true); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + done(); + }); + + request.send(); + }); + + it('Handles error in response when performing a synchronous request.', (done) => { + mockedModules.modules.child_process.execFileSync.returnValue.error = 'Error'; + + request.open('GET', REQUEST_URL, false); + + request.addEventListener('error', () => { + expect(request.status).toBe(0); + expect(request.statusText).toBe('Error'); + expect(request.responseText).toBe(''); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + done(); + }); + + request.send(); + }); }); }); From 2193f38dd7b9203bf3f964fa8688617c92d9131f Mon Sep 17 00:00:00 2001 From: David Ortner Date: Wed, 7 Dec 2022 00:44:20 +0100 Subject: [PATCH 38/40] #463@trivial: Continues on XMLHttpRequest implementation. --- packages/happy-dom/test/setup.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/happy-dom/test/setup.js b/packages/happy-dom/test/setup.js index e9b98fe53..24eb9daee 100644 --- a/packages/happy-dom/test/setup.js +++ b/packages/happy-dom/test/setup.js @@ -181,13 +181,14 @@ const httpMock = () => { return { request: (options, callback) => { let errorCallback = null; + let timeout = null; global.mockedModules.modules.http.request.parameters = { options }; const request = { write: (chunk) => (global.mockedModules.modules.http.request.internal.body += chunk), end: () => { - setTimeout(() => { + timeout = setTimeout(() => { if (global.mockedModules.modules.http.request.returnValue.request.error) { if (errorCallback) { errorCallback(global.mockedModules.modules.http.request.returnValue.request.error); @@ -226,6 +227,7 @@ const httpMock = () => { return request; }, destroy: () => { + clearTimeout(timeout); global.mockedModules.modules.http.request.internal.destroyed = true; } }; From 7bf4bd5d78d4c82ec752adbe24707c02bbb02a8e Mon Sep 17 00:00:00 2001 From: David Ortner Date: Wed, 7 Dec 2022 15:14:10 +0100 Subject: [PATCH 39/40] #463@trivial: Continues on XMLHttpRequest implementation. --- package-lock.json | 31 +++++------ .../async-task-manager/AsyncTaskManager.ts | 8 +-- .../src/xml-http-request/XMLHttpRequest.ts | 12 +++-- .../xml-http-request/XMLHttpRequest.test.ts | 51 ++++++++++++++++++- 4 files changed, 76 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 06fb14826..16eb979b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -647,11 +647,11 @@ } }, "node_modules/@babel/runtime": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.1.tgz", - "integrity": "sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg==", + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", + "integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", "dependencies": { - "regenerator-runtime": "^0.13.10" + "regenerator-runtime": "^0.13.11" }, "engines": { "node": ">=6.9.0" @@ -5709,9 +5709,9 @@ "dev": true }, "node_modules/decode-uri-component": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", "engines": { "node": ">=0.10" } @@ -13821,7 +13821,8 @@ "node_modules/sourcemap-codec": { "version": "1.4.8", "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==" + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead" }, "node_modules/spdx-correct": { "version": "3.1.1", @@ -15931,11 +15932,11 @@ } }, "@babel/runtime": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.1.tgz", - "integrity": "sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg==", + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", + "integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", "requires": { - "regenerator-runtime": "^0.13.10" + "regenerator-runtime": "^0.13.11" } }, "@babel/template": { @@ -19918,9 +19919,9 @@ "dev": true }, "decode-uri-component": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==" + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==" }, "dedent": { "version": "0.7.0", diff --git a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts index b87ae01cd..c7751831a 100644 --- a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts +++ b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts @@ -41,12 +41,8 @@ export default class AsyncTaskManager { global.clearTimeout(timer); } - try { - for (const key of Object.keys(runningTasks)) { - runningTasks[key](); - } - } catch (e) { - error = e; + for (const key of Object.keys(runningTasks)) { + runningTasks[key](); } for (const promise of promises) { diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts index 24a402913..9fc13d972 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequest.ts @@ -670,11 +670,20 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { options, async (response: HTTP.IncomingMessage) => { await this._onAsyncResponse(response, options, ssl, data); + resolve(); + + // Ends async task in Happy DOM + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask( + this._state.asyncTaskID + ); } ).on('error', (error: Error) => { this._onError(error); resolve(); + + // Ends async task in Happy DOM + this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._state.asyncTaskID); }); // Node 0.4 and later won't accept empty data. Make sure it's needed. @@ -685,9 +694,6 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget { this._state.asyncRequest.end(); this.dispatchEvent(new Event('loadstart')); - - // Ends async task in Happy DOM - this._ownerDocument.defaultView.happyDOM.asyncTaskManager.endTask(this._state.asyncTaskID); }); } diff --git a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts index 00f89dfb8..886f36e7d 100644 --- a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts +++ b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts @@ -552,7 +552,7 @@ describe('XMLHttpRequest', () => { expect(request.responseText).toBe('child_process.execFileSync.returnValue.data.text'); }); - it('Performs an asynchronous GET request with the HTTP protocol.', (done) => { + it('Performs an asynchronous GET request with the HTTP protocol listening to the "loadend" event.', (done) => { const expectedResponseText = 'http.request.body'; const windowURL = 'http://localhost:8080'; @@ -570,7 +570,7 @@ describe('XMLHttpRequest', () => { expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.loading); }); - request.addEventListener('load', () => { + request.addEventListener('loadend', () => { expect(mockedModules.modules.http.request.parameters.options).toEqual({ host: 'localhost', port: 8080, @@ -887,5 +887,52 @@ describe('XMLHttpRequest', () => { request.send(); }); + + it('Handles Happy DOM asynchrounous tasks.', async () => { + request.open('GET', REQUEST_URL, true); + request.send(); + + await window.happyDOM.whenAsyncComplete(); + + expect(request.responseText).toBe('http.request.body'); + }); + }); + + describe('abort()', () => { + it('Aborts an asynchrounous request.', () => { + let isAbortTriggered = false; + let isLoadEndTriggered = false; + request.open('GET', REQUEST_URL, true); + request.send(); + request.addEventListener('abort', () => { + isAbortTriggered = true; + }); + request.addEventListener('loadend', () => { + isLoadEndTriggered = true; + }); + request.abort(); + expect(isAbortTriggered).toBe(true); + expect(isLoadEndTriggered).toBe(true); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.unsent); + }); + + it('Ends an ongoing Happy DOM asynchrounous task.', async () => { + request.open('GET', REQUEST_URL, true); + request.send(); + request.abort(); + + await window.happyDOM.whenAsyncComplete(); + + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.unsent); + }); + + it('Aborts an ongoing request when cancelling all Happy DOM asynchrounous tasks.', async () => { + request.open('GET', REQUEST_URL, true); + request.send(); + + window.happyDOM.cancelAsync(); + + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.unsent); + }); }); }); From 2e45e3e8459a8200d55418fe709a6ea2d34228e1 Mon Sep 17 00:00:00 2001 From: David Ortner Date: Wed, 7 Dec 2022 16:45:46 +0100 Subject: [PATCH 40/40] #463@trivial: Continues on XMLHttpRequest implementation. --- packages/happy-dom/src/fetch/FetchHandler.ts | 40 ++++++-- packages/happy-dom/src/fetch/RequestInfo.ts | 6 ++ packages/happy-dom/src/window/IWindow.ts | 3 +- packages/happy-dom/src/window/Window.ts | 3 +- .../XMLHttpRequestCertificate.ts | 94 +++++++++---------- packages/happy-dom/test/setup.js | 12 ++- packages/happy-dom/test/window/Window.test.ts | 85 ++++++++++++----- .../test/javascript/JavaScript.test.ts | 43 +++++++++ 8 files changed, 205 insertions(+), 81 deletions(-) create mode 100644 packages/happy-dom/src/fetch/RequestInfo.ts diff --git a/packages/happy-dom/src/fetch/FetchHandler.ts b/packages/happy-dom/src/fetch/FetchHandler.ts index 3b4e1e4c4..733be38ea 100644 --- a/packages/happy-dom/src/fetch/FetchHandler.ts +++ b/packages/happy-dom/src/fetch/FetchHandler.ts @@ -4,6 +4,9 @@ import IDocument from '../nodes/document/IDocument'; import IResponse from './IResponse'; import Response from './Response'; import NodeFetch from 'node-fetch'; +import Request from './Request'; +import RequestInfo from './RequestInfo'; +import { URL } from 'url'; /** * Helper class for performing fetch. @@ -17,22 +20,47 @@ export default class FetchHandler { * @param [init] Init. * @returns Response. */ - public static fetch(document: IDocument, url: string, init?: IRequestInit): Promise { - // We want to only load NodeFetch when it is needed to improve performance and not have direct dependencies to server side packages. + public static fetch( + document: IDocument, + url: RequestInfo, + init?: IRequestInit + ): Promise { const taskManager = document.defaultView.happyDOM.asyncTaskManager; const requestInit = { ...init, headers: { ...init?.headers } }; + const cookie = document.defaultView.document.cookie; + const referer = document.defaultView.location.origin; + + requestInit.headers['user-agent'] = document.defaultView.navigator.userAgent; // We need set referer to solve anti-hotlinking. // And the browser will set the referer to the origin of the page. - requestInit.headers['referer'] = document.defaultView.location.origin; + // Referer is "null" when the URL is set to "about:blank". + // This is also how the browser behaves. + if (referer !== 'null') { + requestInit.headers['referer'] = referer; + } - requestInit.headers['user-agent'] = document.defaultView.navigator.userAgent; - requestInit.headers['cookie'] = document.defaultView.document.cookie; + if (cookie) { + requestInit.headers['set-cookie'] = cookie; + } + + let request; + + if (typeof url === 'string') { + request = new Request(RelativeURL.getAbsoluteURL(document.defaultView.location, url)); + } else if (url instanceof URL) { + // URLs are always absolute, no need for getAbsoluteURL. + request = new Request(url); + } else { + request = new Request(RelativeURL.getAbsoluteURL(document.defaultView.location, url.url), { + ...url + }); + } return new Promise((resolve, reject) => { const taskID = taskManager.startTask(); - NodeFetch(RelativeURL.getAbsoluteURL(document.defaultView.location, url).href, requestInit) + NodeFetch(request, requestInit) .then((response) => { if (taskManager.getTaskCount() === 0) { reject(new Error('Failed to complete fetch request. Task was canceled.')); diff --git a/packages/happy-dom/src/fetch/RequestInfo.ts b/packages/happy-dom/src/fetch/RequestInfo.ts new file mode 100644 index 000000000..f8d99f25b --- /dev/null +++ b/packages/happy-dom/src/fetch/RequestInfo.ts @@ -0,0 +1,6 @@ +import { URL } from 'url'; +import IRequest from './IRequest'; + +type RequestInfo = IRequest | string | URL; + +export default RequestInfo; diff --git a/packages/happy-dom/src/window/IWindow.ts b/packages/happy-dom/src/window/IWindow.ts index 306b88cda..fd3d72424 100644 --- a/packages/happy-dom/src/window/IWindow.ts +++ b/packages/happy-dom/src/window/IWindow.ts @@ -103,6 +103,7 @@ import { Performance } from 'perf_hooks'; import IElement from '../nodes/element/IElement'; import ProcessingInstruction from '../nodes/processing-instruction/ProcessingInstruction'; import IHappyDOMSettings from './IHappyDOMSettings'; +import RequestInfo from '../fetch/RequestInfo'; /** * Window without dependencies to server side specific packages. @@ -342,7 +343,7 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { * @param [init] Init. * @returns Promise. */ - fetch(url: string, init?: IRequestInit): Promise; + fetch(url: RequestInfo, init?: IRequestInit): Promise; /** * Creates a Base64-encoded ASCII string from a binary string (i.e., a string in which each character in the string is treated as a byte of binary data). diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index 0b1442d97..ed4854f9f 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -114,6 +114,7 @@ import NamedNodeMap from '../named-node-map/NamedNodeMap'; import IElement from '../nodes/element/IElement'; import ProcessingInstruction from '../nodes/processing-instruction/ProcessingInstruction'; import IHappyDOMSettings from './IHappyDOMSettings'; +import RequestInfo from '../fetch/RequestInfo'; const ORIGINAL_SET_TIMEOUT = setTimeout; const ORIGINAL_CLEAR_TIMEOUT = clearTimeout; @@ -659,7 +660,7 @@ export default class Window extends EventTarget implements IWindow { * @param [init] Init. * @returns Promise. */ - public async fetch(url: string, init?: IRequestInit): Promise { + public async fetch(url: RequestInfo, init?: IRequestInit): Promise { return await FetchHandler.fetch(this.document, url, init); } diff --git a/packages/happy-dom/src/xml-http-request/XMLHttpRequestCertificate.ts b/packages/happy-dom/src/xml-http-request/XMLHttpRequestCertificate.ts index 02be3f61a..af8d7744e 100644 --- a/packages/happy-dom/src/xml-http-request/XMLHttpRequestCertificate.ts +++ b/packages/happy-dom/src/xml-http-request/XMLHttpRequestCertificate.ts @@ -1,52 +1,52 @@ // SSL certificate generated for Happy DOM to be able to perform HTTPS requests export default { cert: `-----BEGIN CERTIFICATE----- - MIIDYzCCAkugAwIBAgIUJRKB/H66hpet1VfUlm0CiXqePA4wDQYJKoZIhvcNAQEL - BQAwQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVNYWxt - bzESMBAGA1UECgwJSGFwcHkgRE9NMB4XDTIyMTAxMTIyMDM0OVoXDTMyMTAwODIy - MDM0OVowQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVN - YWxtbzESMBAGA1UECgwJSGFwcHkgRE9NMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A - MIIBCgKCAQEAqerQSQEg/SxVxRiwlItithr5e5EMZo1nsqt/xOxagbmpW3IEmt0j - bpbH7iEF4DDEo7KAOwUCOwVWeFxRoag8lG2ax48wrgjlCna45XDn0Xeg1ARajL04 - gs46HZ0VrzIloVGfln0zgt/Vum5BNqs9Oc5fQoBmoP3cAn3dn4ZVcP0AKthtcyPl - q2DuNRN0PV0D2RtMSiAy9l1Ko6N5x+sAeClDyOL+sTDLngZBVeZyOKt9Id15S8Zt - XtA6VMgHnnF3jChn7pag77rsd/y5iANAVNZYqRl+Eg7xaDcsvbgH46UBOrBcB39Q - tTh5Mtjoxep5e3ZDFG+kQ1HUE+iz5O5n0wIDAQABo1MwUTAdBgNVHQ4EFgQU69s9 - YSobG/m2SN4L/7zTaF7iDbwwHwYDVR0jBBgwFoAU69s9YSobG/m2SN4L/7zTaF7i - DbwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAi/WUXx2oal8L - YnPlIuKfh49n/K18wXSYG//oFYwxfVxqpYH8hUiXVm/GUcXCxS++hUkaKLqXmH9q - MKJiCrZr3vS+2nsBKopkICu/TLdROl0sAI9lByfnEbfSAzjxe1IWJdK8NdY0y5m5 - 9pEr/URVIAp/CxrneyASb4q0Jg5To3FR7vYc+2X6wZn0MundKMg6Dp9/A37jiF3l - Tt/EJp299YZcsUzh+LnRuggRjnoOVu1aLcLFlaUiwZfy9m8mLG6B/mdW/qNzNMh9 - Oqvg1zfGdpz/4D/2UUUBn6pq1vbsoAaF3OesoA3mfDcegDf/H9woJlpT0Wql+e68 - Y3FblSokcA== - -----END CERTIFICATE-----`, +MIIDYzCCAkugAwIBAgIUJRKB/H66hpet1VfUlm0CiXqePA4wDQYJKoZIhvcNAQEL +BQAwQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVNYWxt +bzESMBAGA1UECgwJSGFwcHkgRE9NMB4XDTIyMTAxMTIyMDM0OVoXDTMyMTAwODIy +MDM0OVowQTELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVNrYW5lMQ4wDAYDVQQHDAVN +YWxtbzESMBAGA1UECgwJSGFwcHkgRE9NMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAqerQSQEg/SxVxRiwlItithr5e5EMZo1nsqt/xOxagbmpW3IEmt0j +bpbH7iEF4DDEo7KAOwUCOwVWeFxRoag8lG2ax48wrgjlCna45XDn0Xeg1ARajL04 +gs46HZ0VrzIloVGfln0zgt/Vum5BNqs9Oc5fQoBmoP3cAn3dn4ZVcP0AKthtcyPl +q2DuNRN0PV0D2RtMSiAy9l1Ko6N5x+sAeClDyOL+sTDLngZBVeZyOKt9Id15S8Zt +XtA6VMgHnnF3jChn7pag77rsd/y5iANAVNZYqRl+Eg7xaDcsvbgH46UBOrBcB39Q +tTh5Mtjoxep5e3ZDFG+kQ1HUE+iz5O5n0wIDAQABo1MwUTAdBgNVHQ4EFgQU69s9 +YSobG/m2SN4L/7zTaF7iDbwwHwYDVR0jBBgwFoAU69s9YSobG/m2SN4L/7zTaF7i +DbwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAi/WUXx2oal8L +YnPlIuKfh49n/K18wXSYG//oFYwxfVxqpYH8hUiXVm/GUcXCxS++hUkaKLqXmH9q +MKJiCrZr3vS+2nsBKopkICu/TLdROl0sAI9lByfnEbfSAzjxe1IWJdK8NdY0y5m5 +9pEr/URVIAp/CxrneyASb4q0Jg5To3FR7vYc+2X6wZn0MundKMg6Dp9/A37jiF3l +Tt/EJp299YZcsUzh+LnRuggRjnoOVu1aLcLFlaUiwZfy9m8mLG6B/mdW/qNzNMh9 +Oqvg1zfGdpz/4D/2UUUBn6pq1vbsoAaF3OesoA3mfDcegDf/H9woJlpT0Wql+e68 +Y3FblSokcA== +-----END CERTIFICATE-----`, key: `-----BEGIN PRIVATE KEY----- - MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCp6tBJASD9LFXF - GLCUi2K2Gvl7kQxmjWeyq3/E7FqBualbcgSa3SNulsfuIQXgMMSjsoA7BQI7BVZ4 - XFGhqDyUbZrHjzCuCOUKdrjlcOfRd6DUBFqMvTiCzjodnRWvMiWhUZ+WfTOC39W6 - bkE2qz05zl9CgGag/dwCfd2fhlVw/QAq2G1zI+WrYO41E3Q9XQPZG0xKIDL2XUqj - o3nH6wB4KUPI4v6xMMueBkFV5nI4q30h3XlLxm1e0DpUyAeecXeMKGfulqDvuux3 - /LmIA0BU1lipGX4SDvFoNyy9uAfjpQE6sFwHf1C1OHky2OjF6nl7dkMUb6RDUdQT - 6LPk7mfTAgMBAAECggEAKkwTkTjAt4UjzK56tl+EMQTB+ep/hb/JgoaChci4Nva6 - m9LkJpDJ0yuhlTuPNOGu8XjrxsVWas7HWarRf0Zb3i7yip6wZYI9Ub+AA015x4DZ - /i0fRU2NFbK0cM67qSL4jxG8gj+kZP3HPGNZxHwX/53JxMolwgmvjMc8NgvAlSFd - NnV9h4xtbhUh1NGS5zmP3iU2rwnE8JrIEzwy6axLom7nekAgkdcbAr0UoBs8gcgH - aYNhU4Gz3tGcZZ0IXAfT/bJIH1Ko8AGv4pssWc3BXcmmNdm/+kzvHIxEIV7Qegmo - XG1ZyZCyD/0b4/3e8ySDBEDqwR+HeyTW2isWG2agAQKBgQDp44aTwr3dkIXY30xv - FPfUOipg/B49dWnffYJ9MWc1FT9ijNPAngWSk0EIiEQIazICcUBI4Yji6/KeyqLJ - GdLpDi1CkKqtyh73mjELinYp3EUQgEa77aQogGa2+nMOVfu+O5CtloUrv/A18jX3 - +VEyaEASK0fWmnSI0OdlxQHIAQKBgQC5+xOls2F3MlKASvWRLlnW1wHqlDTtVoYg - 5Nh8syZH4Ci2UH8tON3A5/7SWNM0t1cgV6Cw4zW8Z2spgIT/W0iYYrQ4hHL1xdCu - +CxL1km4Gy8Uwpsd+KdFahFqF/XTmLzW0HXLxWSK0fTwmdV0SFrKF3MXfTCU2AeZ - jJoMFb6P0wKBgQC3Odw6s0vkYAzLGhuZxfZkVvDOK5RRF0NKpttr0iEFL9EJFkPo - 2KKK8jr3QTDy229BBJGUxsJi6u6VwS8HlehpVQbV59kd7oKV/EBBx0XMg1fDlopT - PNbmN7i/zbIG4AsoOyebJZjL7kBzMn1e9vzKHWtcEHXlw/hZGja8vjooAQKBgAeg - xK2HLfg1mCyq5meN/yFQsENu0LzrT5UJzddPgcJw7zqLEqxIKNBAs7Ls8by3yFsL - PQwERa/0jfCl1M6kb9XQNpQa2pw6ANUsWKTDpUJn2wZ+9N3F1RaDwzMWyH5lRVmK - M0qoTfdjpSg5Jwgd75taWt4bxGJWeflSSv8z5R0BAoGAWL8c527AbeBvx2tOYKkD - 2TFranvANNcoMrbeviZSkkGvMNDP3p8b6juJwXOIeWNr8q4vFgCzLmq6d1/9gYm2 - 3XJwwyD0LKlqzkBrrKU47qrnmMosUrIRlrAzd3HbShOptxc6Iz2apSaUDKGKXkaw - gl5OpEjeliU7Mus0BVS858g= - -----END PRIVATE KEY-----` +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCp6tBJASD9LFXF +GLCUi2K2Gvl7kQxmjWeyq3/E7FqBualbcgSa3SNulsfuIQXgMMSjsoA7BQI7BVZ4 +XFGhqDyUbZrHjzCuCOUKdrjlcOfRd6DUBFqMvTiCzjodnRWvMiWhUZ+WfTOC39W6 +bkE2qz05zl9CgGag/dwCfd2fhlVw/QAq2G1zI+WrYO41E3Q9XQPZG0xKIDL2XUqj +o3nH6wB4KUPI4v6xMMueBkFV5nI4q30h3XlLxm1e0DpUyAeecXeMKGfulqDvuux3 +/LmIA0BU1lipGX4SDvFoNyy9uAfjpQE6sFwHf1C1OHky2OjF6nl7dkMUb6RDUdQT +6LPk7mfTAgMBAAECggEAKkwTkTjAt4UjzK56tl+EMQTB+ep/hb/JgoaChci4Nva6 +m9LkJpDJ0yuhlTuPNOGu8XjrxsVWas7HWarRf0Zb3i7yip6wZYI9Ub+AA015x4DZ +/i0fRU2NFbK0cM67qSL4jxG8gj+kZP3HPGNZxHwX/53JxMolwgmvjMc8NgvAlSFd +NnV9h4xtbhUh1NGS5zmP3iU2rwnE8JrIEzwy6axLom7nekAgkdcbAr0UoBs8gcgH +aYNhU4Gz3tGcZZ0IXAfT/bJIH1Ko8AGv4pssWc3BXcmmNdm/+kzvHIxEIV7Qegmo +XG1ZyZCyD/0b4/3e8ySDBEDqwR+HeyTW2isWG2agAQKBgQDp44aTwr3dkIXY30xv +FPfUOipg/B49dWnffYJ9MWc1FT9ijNPAngWSk0EIiEQIazICcUBI4Yji6/KeyqLJ +GdLpDi1CkKqtyh73mjELinYp3EUQgEa77aQogGa2+nMOVfu+O5CtloUrv/A18jX3 ++VEyaEASK0fWmnSI0OdlxQHIAQKBgQC5+xOls2F3MlKASvWRLlnW1wHqlDTtVoYg +5Nh8syZH4Ci2UH8tON3A5/7SWNM0t1cgV6Cw4zW8Z2spgIT/W0iYYrQ4hHL1xdCu ++CxL1km4Gy8Uwpsd+KdFahFqF/XTmLzW0HXLxWSK0fTwmdV0SFrKF3MXfTCU2AeZ +jJoMFb6P0wKBgQC3Odw6s0vkYAzLGhuZxfZkVvDOK5RRF0NKpttr0iEFL9EJFkPo +2KKK8jr3QTDy229BBJGUxsJi6u6VwS8HlehpVQbV59kd7oKV/EBBx0XMg1fDlopT +PNbmN7i/zbIG4AsoOyebJZjL7kBzMn1e9vzKHWtcEHXlw/hZGja8vjooAQKBgAeg +xK2HLfg1mCyq5meN/yFQsENu0LzrT5UJzddPgcJw7zqLEqxIKNBAs7Ls8by3yFsL +PQwERa/0jfCl1M6kb9XQNpQa2pw6ANUsWKTDpUJn2wZ+9N3F1RaDwzMWyH5lRVmK +M0qoTfdjpSg5Jwgd75taWt4bxGJWeflSSv8z5R0BAoGAWL8c527AbeBvx2tOYKkD +2TFranvANNcoMrbeviZSkkGvMNDP3p8b6juJwXOIeWNr8q4vFgCzLmq6d1/9gYm2 +3XJwwyD0LKlqzkBrrKU47qrnmMosUrIRlrAzd3HbShOptxc6Iz2apSaUDKGKXkaw +gl5OpEjeliU7Mus0BVS858g= +-----END PRIVATE KEY-----` }; diff --git a/packages/happy-dom/test/setup.js b/packages/happy-dom/test/setup.js index 24eb9daee..567c1d783 100644 --- a/packages/happy-dom/test/setup.js +++ b/packages/happy-dom/test/setup.js @@ -115,13 +115,19 @@ class NodeFetchResponse { } } -class NodeFetchRequest extends NodeFetchResponse {} +class NodeFetchRequest extends NodeFetchResponse { + constructor(url) { + super(); + this.url = url; + } +} + class NodeFetchHeaders {} jest.mock('node-fetch', () => { return Object.assign( - (url, options) => { - global.mockedModules.modules['node-fetch'].parameters.url = url; + (request, options) => { + global.mockedModules.modules['node-fetch'].parameters.url = request.url.href; global.mockedModules.modules['node-fetch'].parameters.init = options; if (global.mockedModules.modules['node-fetch'].error) { return Promise.reject(global.mockedModules.modules['node-fetch'].returnValue.error); diff --git a/packages/happy-dom/test/window/Window.test.ts b/packages/happy-dom/test/window/Window.test.ts index 3528fb5b9..857ed964c 100644 --- a/packages/happy-dom/test/window/Window.test.ts +++ b/packages/happy-dom/test/window/Window.test.ts @@ -12,6 +12,7 @@ import Selection from '../../src/selection/Selection'; import DOMException from '../../src/exception/DOMException'; import DOMExceptionNameEnum from '../../src/exception/DOMExceptionNameEnum'; import CustomElement from '../../test/CustomElement'; +import { URL } from 'url'; describe('Window', () => { let window: IWindow; @@ -457,48 +458,85 @@ describe('Window', () => { describe('fetch()', () => { for (const method of ['arrayBuffer', 'blob', 'buffer', 'json', 'text', 'textConverted']) { it(`Handles successful "${method}" request.`, async () => { + window.location.href = 'https://localhost:8080'; + document.cookie = 'name1=value1'; + document.cookie = 'name2=value2'; + const expectedUrl = 'https://localhost:8080/path/'; - const expectedOptions = {}; + const expectedOptions = { + method: 'PUT', + headers: { + 'test-header': 'test-value' + } + }; const response = await window.fetch(expectedUrl, expectedOptions); const result = await response[method](); + expect(mockedModules.modules['node-fetch'].parameters.init).toEqual({ + ...expectedOptions, + headers: { + ...expectedOptions.headers, + 'user-agent': window.navigator.userAgent, + 'set-cookie': 'name1=value1; name2=value2', + referer: window.location.origin + } + }); expect(mockedModules.modules['node-fetch'].parameters.url).toBe(expectedUrl); - - expect(mockedModules.modules['node-fetch'].parameters.init.headers['user-agent']).toBe( - window.navigator.userAgent - ); - expect(mockedModules.modules['node-fetch'].parameters.init.headers['cookie']).toBe( - window.document.cookie - ); - expect(mockedModules.modules['node-fetch'].parameters.init.headers['referer']).toBe( - window.location.origin - ); - expect(result).toEqual(mockedModules.modules['node-fetch'].returnValue.response[method]); }); } it('Handles relative URL.', async () => { const expectedPath = '/path/'; - const expectedOptions = {}; window.location.href = 'https://localhost:8080'; - const response = await window.fetch(expectedPath, expectedOptions); + const response = await window.fetch(expectedPath); const textResponse = await response.text(); expect(mockedModules.modules['node-fetch'].parameters.url).toBe( 'https://localhost:8080' + expectedPath ); - expect(mockedModules.modules['node-fetch'].parameters.init.headers['user-agent']).toBe( - window.navigator.userAgent - ); - expect(mockedModules.modules['node-fetch'].parameters.init.headers['cookie']).toBe( - window.document.cookie - ); - expect(mockedModules.modules['node-fetch'].parameters.init.headers['referer']).toBe( - window.location.origin + expect(textResponse).toEqual(mockedModules.modules['node-fetch'].returnValue.response.text); + }); + + it('Handles URL object.', async () => { + const expectedURL = 'https://localhost:8080/path/'; + + window.location.href = 'https://localhost:8080'; + + const response = await window.fetch(new URL(expectedURL)); + const textResponse = await response.text(); + + expect(mockedModules.modules['node-fetch'].parameters.url).toBe(expectedURL); + + expect(textResponse).toEqual(mockedModules.modules['node-fetch'].returnValue.response.text); + }); + + it('Handles Request object with absolute URL.', async () => { + const expectedURL = 'https://localhost:8080/path/'; + + window.location.href = 'https://localhost:8080'; + + const response = await window.fetch(new window.Request(expectedURL)); + const textResponse = await response.text(); + + expect(mockedModules.modules['node-fetch'].parameters.url).toBe(expectedURL); + + expect(textResponse).toEqual(mockedModules.modules['node-fetch'].returnValue.response.text); + }); + + it('Handles Request object with relative URL.', async () => { + const expectedPath = '/path/'; + + window.location.href = 'https://localhost:8080'; + + const response = await window.fetch(new window.Request(expectedPath)); + const textResponse = await response.text(); + + expect(mockedModules.modules['node-fetch'].parameters.url).toBe( + 'https://localhost:8080' + expectedPath ); expect(textResponse).toEqual(mockedModules.modules['node-fetch'].returnValue.response.text); @@ -507,8 +545,9 @@ describe('Window', () => { it('Handles error JSON request.', async () => { mockedModules.modules['node-fetch'].returnValue.error = new Error('error'); window.location.href = 'https://localhost:8080'; + try { - await window.fetch('/url/', {}); + await window.fetch('/url/'); } catch (error) { expect(error).toBe(mockedModules.modules['node-fetch'].returnValue.error); } diff --git a/packages/jest-environment/test/javascript/JavaScript.test.ts b/packages/jest-environment/test/javascript/JavaScript.test.ts index 3729d5838..5c952d170 100644 --- a/packages/jest-environment/test/javascript/JavaScript.test.ts +++ b/packages/jest-environment/test/javascript/JavaScript.test.ts @@ -26,6 +26,49 @@ describe('JavaScript', () => { expect(body.includes('node_modules')).toBe(true); }); + it('Can perform a real asynchronous XMLHttpRequest request to Github.com', (done) => { + const request = new XMLHttpRequest(); + + request.open( + 'GET', + 'https://raw.githubusercontent.com/capricorn86/happy-dom/master/.gitignore', + true + ); + + request.addEventListener('load', () => { + expect(request.getResponseHeader('content-type')).toBe('text/plain; charset=utf-8'); + expect(request.responseText.includes('node_modules')).toBe(true); + expect(request.status).toBe(200); + expect(request.statusText).toBe('OK'); + expect(request.responseURL).toBe( + 'https://raw.githubusercontent.com/capricorn86/happy-dom/master/.gitignore' + ); + done(); + }); + + request.send(); + }); + + it('Can perform a real synchronous XMLHttpRequest request to Github.com', () => { + const request = new XMLHttpRequest(); + + request.open( + 'GET', + 'https://raw.githubusercontent.com/capricorn86/happy-dom/master/.gitignore', + false + ); + + request.send(); + + expect(request.getResponseHeader('content-type')).toBe('text/plain; charset=utf-8'); + expect(request.responseText.includes('node_modules')).toBe(true); + expect(request.status).toBe(200); + expect(request.statusText).toBe('OK'); + expect(request.responseURL).toBe( + 'https://raw.githubusercontent.com/capricorn86/happy-dom/master/.gitignore' + ); + }); + it('Binds global methods to the Window context', () => { const eventListener = (): void => {}; addEventListener('click', eventListener);