Anakunda / xhrLib

// ==UserScript==
// ==UserLibrary==
// @name         xhrLib
// @namespace    https://openuserjs.org/users/Anakunda
// @copyright    2021, Anakunda (https://openuserjs.org/users/Anakunda)
// @license      GPL-3.0-or-later
// @version      1.24.14
// @author       Anakunda
// @exclude      *
// ==/UserScript==
// ==/UserLibrary==

'use strict';

var domParser = new DOMParser;
var xhrLibmaxRetries = typeof GM_getValue == 'function' ? GM_getValue('xhr_max_retries', 60) : 60;
var xhrRetryTimeout = typeof GM_getValue == 'function' ? GM_getValue('xhr_retry_timeout', 1000) : 1000;
if (typeof GM_getValue == 'function') var xhrTimeout = GM_getValue('xhr_timeout');
if (!(xhrTimeout > 0)) xhrTimeout = undefined;
const recoverableHttpErrors = [
    0,
    500, 502, 503, 504,
    520, /*521, */522, 523, 524, 525, /*526, */527, 530,
];

// let isBrokenTM = false;
// if (typeof GM_info == 'object') try {
// 	isBrokenTM = GM_info.scriptHandler == 'Tampermonkey' && /^(?:4\.12|4\.13)\b/.test(GM_info.version);
// } catch(e) { console.warn(e) }

function localXHR(url, params, data) {
	return url ? new Promise(function(resolve, reject) {
		function getParam(key) {
			if (key && params && typeof params == 'object') key = key.toLowerCase(); else return undefined;
			return (key = Object.keys(params).find(_key => _key.toLowerCase() == key)) && params[key];
		}
		function request() {
			xhr.open(method, url, true);
    		if (accept) xhr.setRequestHeader('Accept', accept);
    		if (contentType) xhr.setRequestHeader('Content-Type', contentType);
    		if (headers && typeof headers == 'object') for (let key in headers)
                xhr.setRequestHeader(key, headers[key]);
            xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
			xhr.send(data);
		}
		function errorHandler() {
			if (this.statusText == 'Too Many Requests' && (this.status != 0 || this.responseURL)
					&& xhrLibmaxRetries > 0 && ++retryCounter < xhrLibmaxRetries) {
				defaultErrorHandler(this);
				setTimeout(request, 5000);
			} else if (recoverableHttpErrors.includes(this.status) && (this.status != 0 || this.responseURL)
					&& xhrLibmaxRetries > 0 && ++retryCounter < xhrLibmaxRetries) {
				defaultErrorHandler(this);
				setTimeout(request, xhrRetryTimeout);
			} else {
				const reason = defaultErrorHandler(this);
				if (this.response && ['json', 'application/json'].includes(this.responseType)) try {
					console.warn('Error response:', this.response);
				} catch(e) { }
				reject(reason);
			}
		}

		const responseSuccess = (xhr, strict = false) => xhr.status >= 200 && xhr.status < 400
			|| !strict && xhr.status == 0 && !xhr.responseURL;
		const xhr = new XMLHttpRequest, method = data ? 'POST' : (getParam('method') || 'GET').toUpperCase();
		let retryCounter = 0, timeout = getParam('timeout'), noResponse = false, accept, contentType;
		const responseType = getParam('responseType');
		if (method != 'HEAD') switch (responseType) {
			case null: noResponse = true; break;
			case undefined: xhr.responseType = 'document'; break;
			default: if (responseType) xhr.responseType = responseType.toLowerCase();
		}
		switch (xhr.responseType) {
			case 'document': case 'text/html': case 'html': accept = 'text/html'; break;
			case 'xml': case 'text/xml': accept = 'text/xml'; break;
			case 'json': case 'application/json': accept = 'application/json'; break;
			case 'text': case 'text/plain': accept = 'text/plain'; break;
			default: accept = '*/*';
		}
		if (!(timeout > 0) && xhrTimeout > 0) timeout = xhrTimeout;
		if (timeout > 0) xhr.timeout = timeout;
		if (data === undefined) data = getParam('data') || getParam('body');
		if (data !== undefined) {
			if (data instanceof FormData) {
				//contentType = 'multipart/form-data; charset=utf-8';
			} else if (data instanceof URLSearchParams) {
				//data = data.toString();
				//contentType = 'application/x-www-form-urlencoded; charset=utf-8';
			} else if (data instanceof ArrayBuffer) {
				//contentType = 'application/arraybuffer';
			} else if (data instanceof Blob || data instanceof File) {
				//contentType = 'application/arraybuffer';
			} else if (typeof data == 'object') {
				data = JSON.stringify(data);
				contentType = 'application/json; charset=UTF-8';
			} else if (typeof data == 'string') {
				contentType = 'text/plain; charset=UTF-8';
			}
		}
		const headers = getParam('headers');
		if (method == 'HEAD') {
			if (xhr.responseType) xhr.responseType = '';
			xhr.onreadystatechange = function() {
				if (this.readyState != XMLHttpRequest.HEADERS_RECEIVED) return;
				if (responseSuccess(this)) resolve(this); else {
					errorHandler.call(this);
					this.abort();
				}
			};
		} else if (noResponse) xhr.onreadystatechange = function() {
			if (this.readyState != XMLHttpRequest.DONE) return;
			if (responseSuccess(this)) resolve(this.status); else errorHandler.call(this);
		}; else xhr.onload = function() {
			if (responseSuccess(this)) resolve(this.response); else errorHandler.call(this);
		};
		xhr.onerror = errorHandler;
		xhr.ontimeout = function() {
			/*if (xhrLibmaxRetries > 0 && ++retryCounter < xhrLibmaxRetries) {
				defaultTimeoutHandler(this);
				setTimeout(request, xhrRetryTimeout);
			} else */reject(defaultTimeoutHandler(this));
		};
		//console.debug('XMLHttpRequest.send(...):', xhr);
		request();
	}) : Promise.reject(new Error('URL missing'));
}

function globalXHR(url, params, data) {
	return url ? new Promise(function(resolve, reject) {
		function addFunc(response) {
			response.getHeaderValue = function(key) {
				if (!key || typeof key != 'string') return undefined;
				let entries = this.responseHeaders.split(/\r?\n/)
					.map(line => /^(\S+)\s*:\s*(.*)$/.test(line) ? [RegExp.$1.toLowerCase(), RegExp.$2] : null)
					.filter(Array.isArray);
				return new Map(entries).get(key.toLowerCase());
			};
		}
		function errorHandler(response) {
			if (response.statusText == 'Too Many Requests' && (response.status != 0 || response.finalUrl)
					&& xhrLibmaxRetries > 0 && ++retryCounter < xhrLibmaxRetries) {
				defaultErrorHandler(response);
				console.log('globalXHR retry', retryCounter, '/', xhrLibmaxRetries, '(' + response.status + ')');
				setTimeout(() => { hXHR = GM_xmlhttpRequest(localParams) }, xhrRetryTimeout);
			} else if (recoverableHttpErrors.includes(response.status) && (response.status != 0 || response.finalUrl)
					&& xhrLibmaxRetries > 0 && ++retryCounter < xhrLibmaxRetries) {
				defaultErrorHandler(response);
				console.log('globalXHR retry', retryCounter, '/', xhrLibmaxRetries, '(' + response.status + ')');
				setTimeout(() => { hXHR = GM_xmlhttpRequest(localParams) }, xhrRetryTimeout);
			} else {
				const reason = defaultErrorHandler(response);
				if (response.responseText && ['json', 'application/json'].includes(localParams.responseType)) try {
					console.warn('Error response:', response.response);
				} catch(e) { }
				reject(reason);
			}
		}

		const responseSuccess = (response, strict = false) => response.status >= 200 && response.status < 400
			|| !strict && response.status == 0 && !response.finalUrl;
		const localParams = Object.assign({ url: url }, params);
		localParams.headers = Object.assign({ }, params && params.headers);
		localParams.method = data ? 'POST' : localParams.method ? localParams.method.toUpperCase() : 'GET';
		let retryCounter = 0, noResponse = false;
		if (localParams.method != 'HEAD') switch (localParams.responseType) {
			case null: delete localParams.responseType; noResponse = true; break;
			case undefined: localParams.responseType = 'document'; break;
			default: if (localParams.responseType) localParams.responseType = localParams.responseType.toLowerCase();
				else delete localParams.responseType;
		}
		//if (isBrokenTM && localParams.responseType == 'document') localParams.responseType = 'text/html';
		if (typeof localParams.headers != 'object') localParams.headers = { };
		if (!localParams.headers.Accept) switch (localParams.responseType) {
			case 'document': case 'text/html': case 'html': localParams.headers.Accept = 'text/html'; break;
			case 'xml': case 'text/xml': localParams.headers.Accept = 'text/xml'; break;
			case 'json': case 'application/json': localParams.headers.Accept = 'application/json'; break;
			case 'text': case 'text/plain': localParams.headers.Accept = 'text/plain'; break;
			default: localParams.headers.Accept = '*/*';
		}
		if (!localParams.fetch) localParams.headers['X-Requested-With'] = 'XMLHttpRequest';
		if (localParams.method == 'HEAD') {
			if ('responseType' in localParams) delete localParams.responseType;
			localParams.onreadystatechange = function(response) {
				if (response.readyState != XMLHttpRequest.HEADERS_RECEIVED) return;
				if (responseSuccess(response)) {
					addFunc(response);
					resolve(response);
				} else {
					hXHR.abort();
					errorHandler(response);
				}
			};
		} else if (noResponse) localParams.onreadystatechange = function(response) {
			if (response.readyState != XMLHttpRequest.DONE) return;
			if (responseSuccess(response)) resolve(response.status); else errorHandler(response);
		}; else localParams.onload = function(response) {
			if (responseSuccess(response)) {
				switch (localParams.responseType) {
					case 'document': case 'text/html': case 'html':
						if (response.response instanceof HTMLDocument) response.document = response.response;
						else if (response.responseText) try {
							response.document = domParser.parseFromString(response.responseText, 'text/html');
						} catch(e) {
							console.warn('globalXHR(...) response not valid HTML:', response.responseText, e);
							response.document = null;
						}
						break;
					case 'json': case 'application/json':
						if (typeof response.response == 'object') response.json = response.response;
						else if (response.responseText) try {
							response.json = JSON.parse(response.responseText);
						} catch(e) {
							console.warn('globalXHR(...) response not valid JSON:', response.responseText, e);
							response.json = null;
						}
						break;
					case 'xml': case 'text/xml':
						if (response.response instanceof XMLDocument) response.xml = response.response;
						else if (response.responseText) try {
							response.xml = domParser.parseFromString(response.responseText, 'text/xml');
						} catch(e) {
							console.warn('globalXHR(...) response not valid XML:', response.responseText, e);
							response.xml = null;
						}
						break;
					case 'text': case 'text/plain':
						break;
				}
				addFunc(response);
				resolve(response);
			} else errorHandler(response);
		};
		localParams.onerror = errorHandler;
		localParams.ontimeout = function(response) {
			/*if (xhrLibmaxRetries > 0 && ++retryCounter < xhrLibmaxRetries) {
				defaultTimeoutHandler(response);
				console.log('globalXHR retry', retryCounter, '/', xhrLibmaxRetries, response.status);
				setTimeout(() => { hXHR = GM_xmlhttpRequest(localParams) }, xhrRetryTimeout);
			} else */reject(defaultTimeoutHandler(response));
		};
		if (data === undefined) data = localParams.body;
		if (data) {
			if (data instanceof FormData) {
				//localParams.headers['Content-Type'] = 'multipart/form-data; charset=utf-8';
				localParams.data = data;
			} else if (data instanceof URLSearchParams) {
				localParams.data = data.toString();
				localParams.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=utf-8';
			} else if (data instanceof ArrayBuffer) {
				localParams.data = data;
			} else if (data instanceof Blob || data instanceof File) {
				localParams.data = data;
			} else if (typeof data == 'object') {
				localParams.data = JSON.stringify(data);
				localParams.headers['Content-Type'] = 'application/json; charset=UTF-8';
			} else if (typeof data == 'string') {
				localParams.data = data;
				localParams.headers['Content-Type'] = 'text/plain; charset=UTF-8';
			} else localParams.data = data;
		}
		if ('body' in localParams) delete localParams.body;
		if (!(localParams.timeout > 0) && xhrTimeout > 0) localParams.timeout = xhrTimeout;
		//console.debug('GM_xmlhttpRequest(...):', localParams);
		var hXHR = GM_xmlhttpRequest(localParams);
	}) : Promise.reject('URL missing');
}

function fileReader(filePath, params) {
	const unc = new URL('file:' + filePath);
	return globalXHR(unc, params);
}
function textFileReader(filePath, params) {
	return fileReader(filePath, Object.assign(params || { }, { responseType: 'text' }))
		.then(response => response.responseText);
}

function defaultErrorHandler(response) {
	console.error('HTTP error:', response);
	let reason = 'HTTP error ' + response.status;
	if (response.status == 0) reason += '/' + response.readyState;
	let statusText = response.statusText;
	if (response.response) try {
		if (typeof response.response.error == 'string') statusText = response.response.error;
	} catch(e) { }
	if (statusText) reason += ' (' + statusText + ')';
	return reason;
}

function defaultTimeoutHandler(response) {
	console.error('HTTP timeout:', response);
	let reason = 'HTTP timeout';
	if (response.timeout) reason += ' (' + response.timeout + ')';
	return reason;
}


//////////////////////////////////////////////////////////////////////////
////////////////////////////// SAFE PADDING //////////////////////////////
//////////////////////////////////////////////////////////////////////////