UNPKG

mock-violentmonkey

Version:

Mock violentmonkey's globals for testing userscripts

544 lines 18.1 kB
// Taken from https://github.com/mjwwit/node-XMLHttpRequest (MIT license) and heavily modified by me /** * This is not meant to be completely spec compliant. * It is meant to be spec compliant where it matters for GM_xmlhttpRequest * For example abort before send behaves weirdly (even more so depending on the browser) * but luckily GM_xmlhttpRequest will never abort before send so it doesn't matter */ /** * Wrapper for built-in http.js to emulate the browser XMLHttpRequest object. * * @author Dan DeFelippi <dan@driverdan.com> * @author MeLusc <https://github.com/melusc> * @contributor David Ellis <d.f.ellis@ieee.org> * @license MIT */ import { Buffer, resolveObjectURL } from 'node:buffer'; import process from 'node:process'; import { dataUriToBuffer } from 'data-uri-to-buffer'; import followRedirects from 'follow-redirects'; const { http, https } = followRedirects; const noop = () => { /* Nothing */ }; // Set some default headers const defaultHeaders = { 'User-Agent': 'node-XMLHttpRequest', Accept: '*/*', }; // Only these headers should be forbidden // (not xhr spec compliant, but GM_xmlhttpRequest allows them) const forbiddenRequestHeaders = new Set([ 'accept-encoding', 'content-transfer-encoding', ]); // These request methods are not allowed const allowedRequestMethods = new Set([ 'GET', 'HEAD', 'POST', 'DELETE', 'OPTIONS', 'PUT', ]); /** * Check if the specified header is allowed. * * @param header Header to validate * @return False if not allowed, otherwise true */ const isAllowedHttpHeader = (header) => typeof header === 'string' && !forbiddenRequestHeaders.has(header.toLowerCase()); /** * Check if the specified method is allowed. * * @param method Request method to validate * @return False if not allowed, otherwise true */ const isAllowedHttpMethod = (method) => typeof method === 'string' && allowedRequestMethods.has(method); /** * `XMLHttpRequest` constructor. * * Supported options for the `opts` object are: * * - `agent`: An http.Agent instance; http.globalAgent may be used; if 'undefined', agent usage is disabled * * @param {Object} opts optional "options" object */ class XMLHttpRequest { /** * Constants */ static UNSENT = 0; static OPENED = 1; static HEADERS_RECEIVED = 2; static LOADING = 3; static DONE = 4; UNSENT = 0; OPENED = 1; HEADERS_RECEIVED = 2; LOADING = 3; DONE = 4; onabort; onerror; onload; onloadend; onloadstart; onprogress; onreadystatechange; ontimeout; // Current state readyState = this.UNSENT; // Result & response responseBuffer = Buffer.alloc(0); responseURL = ''; status = 0; statusText = ''; timeout = 0; // Request settings #settings; #options; #response; #request; #headers = { ...defaultHeaders }; // Send flag #sendFlag = false; // Event listeners #listeners = {}; // Error flag, used when errors occur or abort is called #errorFlag = false; #abortedFlag = false; #timeoutFlag = false; /** * @param options Set the options for the request */ constructor(options = {}) { this.#options = options; } /** * Sets a header for the request. * * @param string header Header name * @param string value Header value * @return boolean Header added */ setRequestHeader = (header, value) => { const headers = this.#headers; header = header.toLowerCase(); if (this.readyState !== this.OPENED) { throw new Error('INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN'); } if (!isAllowedHttpHeader(header)) { console.warn(`Attempt to set a forbidden header was denied: ${header}`); return false; } if (this.#sendFlag) { throw new Error('INVALID_STATE_ERR: send flag is true'); } headers[header] = String(value); return true; }; /** * Open the connection. Currently supports local server requests. * * @param string method Connection method (eg GET, POST) * @param string url URL for the connection. * @param string user Username for basic authentication (optional) * @param string password Password for basic authentication (optional) */ open = (method, url, user, password) => { this.abort(); this.#errorFlag = false; this.#abortedFlag = false; method = method.toUpperCase(); // Check for valid request method if (!isAllowedHttpMethod(method)) { throw new Error(`SecurityError: Request method "${method}" not allowed`); } this.#settings = { method, url: String(url), user: user ?? undefined, password: password ?? undefined, }; this.#setState(this.OPENED); }; /** * Gets a header from the server response. * * @param string header Name of header to get. * @return string Text of the header or null if it doesn't exist. */ getResponseHeader = (header) => { const response = this.#response; if (typeof header === 'string' && this.readyState > this.OPENED && response?.headers[header.toLowerCase()]) { return response.headers[header.toLowerCase()]; } return null; }; /** * Gets all the response headers. * * @return string A string with all response headers separated by CR+LF */ getAllResponseHeaders = () => { if (this.readyState < this.HEADERS_RECEIVED || this.#errorFlag || this.#response?.headers === undefined) { return ''; } const result = []; for (const [key, value] of Object.entries(this.#response.headers)) { result.push(`${key}: ${value.toString()}`); } return result.length === 0 ? '' : `${result.join('\r\n')}\r\n`; }; /** * Gets a request header * * @param string name Name of header to get * @return string Returns the request header or empty string if not set */ getRequestHeader = (name) => { const headers = this.#headers; name = name.toLowerCase(); return headers[name] ?? ''; }; /** * Sends the request to the server. * * @param {unknown} data Optional data to send as request body. */ send = (data) => { if (this.readyState !== this.OPENED) { throw new Error('INVALID_STATE_ERR: connection must be opened before send() is called'); } if (this.#sendFlag) { throw new Error('INVALID_STATE_ERR: send has already been called'); } const settings = this.#settings; let url; try { url = new URL(settings.url, this.#options.base); } catch { this.#handleError(); return; } // Here for historical reasons this.#dispatchEvent('readystatechange'); this.responseURL = url.href; // Determine if valid protocol switch (url.protocol) { case 'data:': { this.#fetchDataURI(url); break; } case 'blob:': { void this.#fetchBlob(url); break; } case 'https:': case 'http:': { this.#fetchHttp(url, data); break; } default: { this.#handleError(); } } }; /** * Aborts a request. */ abort = () => { if (this.readyState === this.UNSENT || this.#abortedFlag) { return; } this.#abortedFlag = true; if ((this.readyState === this.OPENED && this.#sendFlag) || this.readyState === this.HEADERS_RECEIVED || this.readyState === this.LOADING) { this.#sendFlag = false; this.#reset(); /** * .abort() is the only function that directly modifies readyState * If you abort in an event listener (like readystatechange) that could cause abort to run before * the rest of the readystatechange event listeners (because everything is synchronous). * That's why it needs to run on the next tick so that all the other event listeners have got to run */ process.nextTick(() => { this.#setState(this.DONE); }); } }; /** * Adds an event listener. Preferred method of binding to events. */ addEventListener = (event, callback) => { event = event.toLowerCase(); (this.#listeners[event] ??= []).push(callback); }; /** * Remove an event callback that has already been bound. * Only works on the matching funciton, cannot be a copy. */ removeEventListener = (event, callback) => { const listeners = this.#listeners; const specificListeners = listeners[event]; if (specificListeners) { for (let index = 0; index < specificListeners.length; ++index) { if (specificListeners[index] === callback) { specificListeners.splice(index--, 1); } } } }; /** * Changes readyState and calls onreadystatechange. * * @param int state New state */ #setState = (state) => { if ((this.readyState === state && state !== this.LOADING) || (this.readyState === this.UNSENT && this.#abortedFlag)) { return; } this.readyState = state; // Not on UNSENT // OPENED gets called seperately if (state > this.OPENED) { this.#dispatchEvent('readystatechange'); } if (state === this.DONE) { let fire; if (this.#errorFlag) { fire = 'error'; } else if (this.#abortedFlag) { fire = 'abort'; } else if (this.#timeoutFlag) { fire = 'timeout'; } else { fire = 'load'; } this.#dispatchEvent(fire); // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) this.#dispatchEvent('loadend'); } }; #fetchDataURI = (url) => { this.#response = { headers: {}, destroy: noop, }; this.#dispatchEvent('loadstart'); try { const parsed = dataUriToBuffer(url.href); this.#simulateEventsWith(parsed.buffer, parsed.typeFull, url, { extraProgressEvent: true, }); } catch { this.#handleError(); } }; /** * @param {URL} url */ #fetchBlob = async (url) => { const blob = resolveObjectURL(url.href); const method = this.#settings?.method ?? 'GET'; this.#dispatchEvent('loadstart'); if (blob === undefined) { this.responseURL = ''; this.#handleError(); } else if (method === 'GET') { const ab = await blob.arrayBuffer(); this.#simulateEventsWith(ab, blob.type, url, { extraProgressEvent: false, }); } else { this.#handleError(url); } }; #simulateEventsWith = (arrayBuffer, type, url, options) => { const buffer = Buffer.from(arrayBuffer); this.#response = { headers: {}, destroy: noop, }; const response = this.#response; response.headers = { 'content-type': type, 'content-length': `${buffer.length}`, }; this.status = 200; this.statusText = http.STATUS_CODES[200]; this.responseURL = url.href; this.#setState(this.HEADERS_RECEIVED); this.responseBuffer = buffer; this.#setState(this.LOADING); this.#dispatchEvent('progress'); if (options.extraProgressEvent && buffer.length > 0) { // Data-uris that aren't empty have two progress events this.#dispatchEvent('progress'); } this.#setState(this.DONE); }; #fetchHttp = (url, data) => { const headers = this.#headers; const settings = this.#settings; const host = url.hostname; const ssl = url.protocol === 'https:'; // 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); // Set the Host header or the server may reject the request headers['host'] = host; if (ssl ? port !== 443 : port !== 80) { headers['host'] += `:${url.port}`; } // Set Basic Auth if necessary if (settings.user) { settings.password ??= ''; const authBuf = Buffer.from(`${settings.user}:${settings.password}`); headers['authorization'] = `Basic ${authBuf.toString('base64')}`; } // Set content length header if (settings.method === 'GET' || settings.method === 'HEAD') { data = undefined; } else if (data) { headers['content-length'] = String(data.length); } else if (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. headers['content-length'] = '0'; } // Reset error flag this.#errorFlag = false; const httpProvider = ssl ? https : http; // Request is being sent, set send flag this.#sendFlag = true; // Handler for the response const responseHandler = async (response) => { if (this.#abortedFlag || this.#errorFlag || this.#timeoutFlag) { response.destroy(); return; } // Set response var to the response we got back // This is so it remains accessable outside this scope this.#response = response; this.responseURL = response.responseUrl; this.#setState(this.HEADERS_RECEIVED); this.status = response.statusCode; const bufferItems = []; const appendBuffer = (buf) => { bufferItems.push(buf); this.responseBuffer = Buffer.concat(bufferItems); }; if (this.timeout > 0) { const timeoutLeft = this.timeout - (Date.now() - start); if (timeoutLeft <= 0) { this.#onTimeout(); return; } response.setTimeout(timeoutLeft, this.#onTimeout); } try { for await (const chunk of response) { appendBuffer(chunk); if (this.#sendFlag) { this.#setState(this.LOADING); } this.#dispatchEvent('progress'); } this.statusText = http.STATUS_CODES[response.statusCode]; if (this.#sendFlag) { // The this.#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(this.DONE); } } catch { this.#handleError(); } }; // Create the request const request = httpProvider .request(url, { method: settings.method, headers, }, responseHandler) .on('error', () => { this.#handleError(); }); const start = Date.now(); if (this.timeout > 0) { request.setTimeout(this.timeout, this.#onTimeout); } this.#request = request; // Node 0.4 and later won't accept empty data. Make sure it's needed. if (data) { request.write(data); } request.end(); this.#dispatchEvent('loadstart'); }; /** * Called when an error is encountered to deal with it. * @param {URL} url If the url is still accessible even on error */ #handleError = (url) => { if (this.#timeoutFlag) { return; } this.responseURL = url?.href ?? ''; this.status = 0; this.responseBuffer = Buffer.alloc(0); this.#errorFlag = true; this.#setState(this.DONE); }; #reset = () => { this.#request?.destroy(); this.#request &&= undefined; this.#response?.destroy(); // Allow access to headers this.#headers = { ...defaultHeaders }; this.responseBuffer = Buffer.alloc(0); this.responseURL = ''; }; #onTimeout = () => { this.#reset(); this.#timeoutFlag = true; this.#setState(this.DONE); }; /** * Dispatch any events, including both "on" methods and events attached using addEventListener. */ #dispatchEvent = (event) => { const onFunction = this[`on${event}`]; const listeners = this.#listeners; const functionArray = listeners[event]; if (typeof onFunction === 'function') { onFunction.call(this); } if (functionArray) { for (const eventHandler of functionArray) { eventHandler.call(this); } } }; } export { XMLHttpRequest }; //# sourceMappingURL=xmlhttprequest.js.map