This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/472943/1320613/Itsnotlupus%27%20MiddleMan.js
// ==UserScript==
// @name Itsnotlupus' MiddleMan
// @namespace Itsnotlupus Industries
// @version 1.5.2
// @description inspect/intercept/modify any network requests
// @author Itsnotlupus
// @license MIT
// ==/UserScript==
/* global globalThis */
const middleMan = (function(window) {
/**
* A small class that lets you register middleware for Fetch/XHR traffic.
*
*/
class MiddleMan {
routes = {
Request: {},
Response: {}
};
regexps = {};
addHook(route, {requestHandler, responseHandler}) {
if (requestHandler) {
this.routes.Request[route]??=[];
this.routes.Request[route].push(requestHandler);
}
if (responseHandler) {
this.routes.Response[route]??=[];
this.routes.Response[route].push(responseHandler);
}
this.regexps[route]??=this.routeToRegexp(route);
}
removeHook(route, {requestHandler, responseHandler}) {
if (requestHandler && this.routes.Request[route]?.includes(requestHandler)) {
const i = this.routes.Request[route].indexOf(requestHandler);
this.routes.Request[route].splice(i,1);
}
if (responseHandler && this.routes.Response[route]?.includes(responseHandler)) {
const i = this.routes.Response[route].indexOf(responseHandler);
this.routes.Response[route].splice(i,1);
}
}
// 2 modes: start with '/' => full regexp, otherwise we only recognize '*" as a wildcard.
routeToRegexp(path) {
const r = path instanceof RegExp ? path :
path.startsWith('/') ?
path.split('/').slice(1,-1).join('') :
['^', ...path.split(/([*])/).map((chunk, i) => i%2==0 ? chunk.replace(/([^a-zA-Z0-9])/g, "\\$1") : '.'+chunk), '$'].join('');
return new RegExp(r);
}
/**
* Call this with a Request or a Response, and it'll loop through
* each relevant hook to inspect and/or transform it.
*/
async process(type, req, res, err) {
const name = type.name;
const routes = this.routes[name], hooks = [];
Object.keys(routes).forEach(k => {
if (req.url.match(this.regexps[k]) || res?.url.match(this.regexps[k])) hooks.push(...routes[k]);
});
for (const hook of hooks) {
try {
switch (type) {
case Request: if (req instanceof type) req = await hook(req.clone()) ?? req; break;
case Response: if (res instanceof type || err) res = await hook(req.clone(), res?.clone(), err) ?? res; break;
}
} catch (e) {
console.error(`MiddleMan: Uncaught exception in ${name} hook for ${req.method??''} ${req.url}!`, e);
}
}
return type == Request ? req : res;
}
}
// The only instance we'll need
const middleMan = new MiddleMan;
// A wrapper for fetch() that plugs into middleMan.
const _fetch = window.fetch;
async function fetch(resource, options) {
const request = new Request(resource, options);
const result = await middleMan.process(Request, request);
const clonedResult = result.clone();
try {
const response = result instanceof Request ? await _fetch(result) : result;
return middleMan.process(Response, clonedResult, response);
} catch (err) {
const otherResponse = middleMan.process(Response, clonedResult, undefined, err);
if (otherResponse instanceof Response) {
return otherResponse;
}
throw err;
}
}
/**
* Polyfill a subset of EventTarget, for the sole purpose of being used in the XHR polyfill below.
* Primarily written to allow Safari to extend it without tripping on itself.
* Various liberties were taken.
* We call ourselves XMLHttpRequestEventTarget because that's a thing, and some well-meaning libraries (zone.js)
* feel compelled to grab methods from this object and call them on XHR instances, so let's make them happy.
*/
class XMLHttpRequestEventTarget {
#listeners = {};
#events = {};
#setEvent(type, f) {
if (this.#events[type]) this.removeEventListener(type, this.#events[type]);
this.#events[type] = typeof f == 'function' ? f : null;
if (this.#events[type]) this.addEventListener(type, this.#events[type]);
}
#getEvent(type) {
return this.#events[type];
}
constructor(events = []) {
events.forEach(type => {
Object.defineProperty(this, "on"+type, {
get() { return this.#getEvent(type); },
set(f) { this.#setEvent(type, f); }
});
});
}
addEventListener(type, listener, options = {}) {
if (options === true) { options = { capture: true }; }
this.#listeners[type]??=[];
this.#listeners[type].push({ listener, options });
options.signal?.addEventListener?.('abort', () => this.removeEventListener(type, listener, options));
}
removeEventListener(type, listener, options = {}) {
if (options === true) { options = { capture: true }; }
if (!this.#listeners[type]) return;
const index = this.#listeners[type].findIndex(slot => slot.listener === listener && slot.options.capture === options.capture);
if (index > -1) {
this.#listeners[type].splice(index,1);
}
}
dispatchEvent(event) {
// no capturing, no bubbling, no preventDefault, no stopPropagation, and a general disdain for most of the event featureset.
const listeners = this.#listeners[event.type];
if (!listeners) return;
// since I can't set event.target, or generally do anything useful with an Event instance, let's Proxy it.
let immediateStop = false;
const eventProxy = new Proxy(event, {
get: (event, prop) => {
switch (prop) {
case "target":
case "currentTarget":
return this;
case "isTrusted":
return true; // you betcha
case "stopImmediatePropagation":
return () => { immediateStop = true };
default: {
const val = Reflect.get(event, prop);
return typeof val =='function' ? new Proxy(val, {
apply(fn, _, args) {
return Reflect.apply(fn, event, args);
}
}) : val;
}
}
}
});
listeners.forEach(({listener, options}) => {
if (immediateStop) return;
if (options.once) this.removeEventListener(eventProxy.type, listener, options);
try {
listener.call(this, eventProxy);
} catch (e) {
// We can't match EventTarget::dispatchEvent throwing behavior in pure JS. oh well. fudge the timing and keep on trucking.
setTimeout(() =>{ throw e });
}
});
return true;
}
get [Symbol.toStringTag]() {
return 'XMLHttpRequestEventTarget';
}
static toString = ()=> 'function XMLHttpRequestEventTarget() { [native code] }';
}
XMLHttpRequestEventTarget.prototype.__proto__ = EventTarget.prototype;
class XMLHttpRequestUpload extends XMLHttpRequestEventTarget {
constructor() {
super(["loadstart","progress","abort","error","load","timeout","loadend"]);
}
get [Symbol.toStringTag]() {
return 'XMLHttpRequestUpload';
}
static toString = ()=> 'function XMLHttpRequestUpload() { [native code] }';
}
/**
* An XMLHttpRequest polyfill written on top of fetch().
* Nothing special here, but this allows MiddleMan to work on XHR too.
*
* A few gotchas:
* - synchronous xhr is not implemented. all my homies hate sync xhr anyway.
* - https://xhr.spec.whatwg.org/ was gently perused, and https://wpt.live/tools/runner/index.html 's output was pondered.
* - In short, this is not spec-compliant. But it can work on a bunch of websites anyway.
*/
class XMLHttpRequest extends XMLHttpRequestEventTarget {
#readyState;
#requestOptions = {};
#requestURL;
#abortController;
#timeout = 0;
#responseType = '';
#mimeTypeOverride = null;
#response;
#responseText;
#responseXML;
#responseAny;
#status; // a response.status override for error conditions.
#finalMimeType;
#finalResponseType;
#finalResponseCharset;
#finalContentType; // mimetype + charset
#textDecoder;
#dataLengthComputable = false;
#dataLoaded = 0;
#dataTotal = 0;
#uploadEventTarget;
#emitUploadErrorEvent;
#errorEvent;
#sendFlag;
UNSENT = 0;
OPENED = 1;
HEADERS_RECEIVED = 2;
LOADING = 3;
DONE = 4;
static UNSENT = 0;
static OPENED = 1;
static HEADERS_RECEIVED = 2;
static LOADING = 3;
static DONE = 4;
constructor() {
super(['abort','error','load','loadend','loadstart','progress','readystatechange','timeout']);
this.#readyState = 0;
}
get readyState() {
return this.#readyState;
}
#assertReadyState(...validValues) {
if (!validValues.includes(this.#readyState)) {
throw new new DOMException("", "InvalidStateError");
}
}
#updateReadyState(value) {
this.#readyState = value;
this.#emitEvent("readystatechange");
}
// Request setup
open(method, url, async, user, password) {
this.#requestOptions.method = method.toString().toUpperCase();
this.#requestOptions.headers = new Headers()
this.#requestURL = url;
this.#abortController = null;
this.#response = null;
this.#responseText = '';
this.#responseAny = null;
this.#responseXML = null;
this.#status = null;
this.#dataLengthComputable = false;
this.#dataLoaded = 0;
this.#dataTotal = 0;
this.#sendFlag = false;
if (async === false) {
throw new Error("Synchronous XHR is not supported.");
// I suspect that if I just let those run asynchronously, it'd be fine 80%+ of the time.
// on the other hand, it's been deprecated for many years, and seems to be primarily used
// for user tracking by devs who can't be bothered to hit newer APIs. so..
}
if (user || password) {
this.#requestOptions.headers.set('Authorization', 'Basic '+btoa(`${user??''}:${password??''}`));
}
this.#updateReadyState(1);
}
setRequestHeader(header, value) {
this.#assertReadyState(1);
if (this.#sendFlag) throw new DOMException("", "InvalidStateError");
this.#requestOptions.headers.set(header, value);
}
overrideMimeType(mimeType) {
this.#assertReadyState(0,1,2);
this.#mimeTypeOverride = mimeType;
}
set responseType(type) {
this.#assertReadyState(0,1,2);
if (!["","arraybuffer","blob","document","json","text"].includes(type)) {
console.warn(`The provided value '${type}' is not a valid enum value of type XMLHttpRequestResponseType.`);
return;
}
this.#responseType = type;
}
get responseType() {
return this.#responseType;
}
set timeout(value) {
const ms = isNaN(Number(value)) ? 0 : Math.floor(Number(value));
this.#timeout = value;
}
get timeout() {
return this.#timeout;
}
get upload() {
Promise.resolve(()=>{ throw new Error("XMLHttpRequestUpload is not implemented."); });
if (!this.#uploadEventTarget) {
this.#uploadEventTarget = new XMLHttpRequestUpload();
}
return this.#uploadEventTarget;
// if the request has a body, we'll dispatch events on the upload event target in the next method.
}
#trackUploadEvents() {
const USE_READABLE_STREAM = false;
let loaded =0, total = 0, hasSize = false, error = false;;
const emitUploadEvent = type => {
this.#uploadEventTarget.dispatchEvent(new ProgressEvent(type, {
lengthComputable: hasSize,
loaded,
total
}));
}
if (!USE_READABLE_STREAM) {
// No good way to track upload progress with fetch() yet. Fake something.
loaded = total;
this.addEventListener("progress", () => {
emitUploadEvent('progress');
emitUploadEvent('load');
emitUploadEvent('loadend');
}, { once: true });
emitUploadEvent('loadstart');
return;
}
this.#emitUploadErrorEvent = type => {
error = true;
hasSize = false;
loaded = total = 0;
emitUploadEvent(type);
emitUploadEvent("loadend");
};
const trackBlob = (blob) => {
total = blob.size;
hasSize = total>0;
this.#requestOptions.duplex = "half";
this.#requestOptions.body = blob.stream().pipeThrough(new TransformStream({
start(controller) {
},
transform(chunk, controller) {
if (error) return;
controller.enqueue(chunk);
loaded += chunk.byteLength;
emitUploadEvent('progress');
},
flush(controller) {
if (error) return;
emitUploadEvent('progress');
emitUploadEvent('load');
emitUploadEvent('loadend');
}
}));
emitUploadEvent('loadstart');
}
const { body } = this.#requestOptions;
if (body instanceof FormData || body instanceof URLSearchParams) {
return new Response(this.#requestOptions.body).blob().then(blob => trackBlob(blob));
} else {
trackBlob(new Blob([body??'']));
}
}
set withCredentials(flag) {
if (this.#sendFlag) throw new DOMException("", "InvalidStateError");
this.#requestOptions.credentials = flag ? "include" : "same-origin";
}
get withCredentials() {
return this.#requestOptions.credentials == "include";
}
send(body = null) {
this.#assertReadyState(1);
if (this.#requestOptions.method != 'GET' && this.#requestOptions.method != 'HEAD') {
switch (true) {
case body instanceof Document: this.#requestOptions.body = body.documentElement.outerHTML; break;
case body instanceof Blob:
case body instanceof ArrayBuffer:
case ArrayBuffer.isView(body): // true for TypedArray and DataView
case body instanceof FormData:
case body instanceof URLSearchParams:
this.#requestOptions.body = body;
break;
default:
this.#requestOptions.body = (body??'')+'';
break;
}
}
if (this.#sendFlag) throw new DOMException("", "InvalidStateError");
this.#sendFlag = true;
const innerSend = () => {
const request = new Request(this.#requestURL, this.#requestOptions);
this.#abortController = new AbortController();
const signal = this.#abortController.signal;
if (this.#timeout) {
setTimeout(()=> this.#timedOut(), this.#timeout);
}
this.#emitEvent("loadstart");
(async ()=> {
let response;
try {
this.#response = await fetch(request, { signal });
let finalResponseType = this.#responseType;
let mimeType = this.#mimeTypeOverride ?? this.#response.headers.get('content-type') ?? 'text/xml';
this.#finalMimeType = mimeType.split(';')[0].trim() ; // header parsing is still iffy
this.#finalResponseCharset = mimeType.match(/;charset=(?<charset>[^;]*)/i)?.groups?.charset ?? "";
try {
this.#textDecoder = new TextDecoder(this.#finalResponseCharset)
} catch {
// garbage charset seen. you get utf-8 and you like it.
this.#textDecoder = new TextDecoder;
}
if (!finalResponseType) {
finalResponseType = ([ 'text/html', 'text/xml', 'application/xml'].includes(this.#finalMimeType) || this.#finalMimeType.endsWith("+xml")) ? 'document' : 'text';
}
this.#finalResponseType = finalResponseType;
this.#finalContentType = (this.#finalMimeType || 'text/xml') + (this.#finalResponseCharset ? ';charset='+this.#finalResponseCharset : '')
this.#updateReadyState(2);
const isNotCompressed = this.#response.type == 'basic' && !this.#response.headers.get('content-encoding');
if (isNotCompressed) {
this.#dataTotal = this.#response.headers.get('content-length') ?? 0;
this.#dataLengthComputable = this.#dataTotal !== 0;
}
await this.#processResponse();
} catch (e) {
return this.#error();
} finally {
this.#sendFlag = false;
}
})();
}
if (this.#uploadEventTarget && this.#requestOptions.body) {
// user asked for .upload, and the request has a body. track upload events.
const promise = this.#trackUploadEvents(this.#requestOptions);
// sadly, some body types cannot be handled synchronously (FormData and URLSearchParams) when using ReadableStream to track upload progress.
// those turn this flow asynchronous (and break some expectations around sync state immediately after send() )
if (promise) return promise.then(innerSend);
}
innerSend();
}
/**
* Spec breakage: When readyState == 1, abort will happen asynchronously.
* (ie nothing will have changed when this function returns.)
*/
abort() {
this.#abortController?.abort();
this.#errorEvent = "abort";
if (this.#readyState > 1) { // too late to send signal abort the fetch itself, resolve manually.
this.#error(true);
}
}
#timedOut() {
this.#abortController?.abort(`XHR aborted due to timeout after ${this.#timeout} ms.`);
this.#errorEvent = "timeout";
}
#error(late) {
// abort and timeout end up here.
this.#response = new Response();
this.#status = 0;
this.#responseText = ''
this.#responseAny = null;
this.#responseXML = null;
this.#dataLoaded = 0;
this.#readyState = 0; // event-less readyState change. somehow.
if (!late) {
this.#updateReadyState(4);
this.#emitUploadErrorEvent?.(this.#errorEvent ?? "error");
this.#emitEvent(this.#errorEvent ?? "error");
this.#emitEvent("loadend");
}
this.#errorEvent = null;
}
async #processResponse() {
this.#trackProgress(this.#response.clone());
switch (this.#finalResponseType) {
case 'arraybuffer':
try {
this.#responseAny = await this.#response.arrayBuffer();
} catch {
this.#responseAny = null;
}
break;
case 'blob':
try {
this.#responseAny = new Blob([await this.#response.arrayBuffer()], { type: this.#finalContentType });
} catch {
this.#responseAny = null;
}
break;
case 'document': {
this.#responseText = this.#textDecoder.decode(await this.#response.arrayBuffer());
try {
this.#responseAny = this.#responseXML = new DOMParser().parseFromString(this.#responseText, this.#finalMimeType);
} catch {
this.#responseAny = null;
}
break;
}
case 'json':
try {
this.#responseAny = await this.#response.json();
} catch {
this.#responseAny = null;
}
break;
case 'text':
default:
this.#responseAny = this.#responseText = this.#textDecoder.decode(await this.#response.arrayBuffer());
break;
}
if (this.#status == 0) {
// blank out the responses.
this.#responseAny = null;
this.#responseXML = null;
this.#responseText = '';
} else {
this.#readyState = 4; //XXX
this.#emitEvent("load");
}
this.#updateReadyState(4);
this.#emitEvent("loadend");
}
async #trackProgress(response) {
if (!response.body) return;
// count the bytes to update #dataLoaded, and add text into #responseText if appropriate
const isText = this.#finalResponseType == 'text';
const reader = response.body.getReader();
const handleChunk = ({ done, value }) => {
if (done) return;
this.#dataLoaded += value.length;
if (isText) {
this.#responseText += this.#textDecoder.decode(value);
this.#responseAny = this.#responseText;
}
if (this.#readyState == 2) this.#updateReadyState(3);
this.#emitEvent('progress');
reader.read().then(handleChunk).catch(()=>0);
};
reader.read().then(handleChunk).catch(()=>0);
}
// Response access
getResponseHeader(header) {
try {
return this.#response?.headers.get(header) ?? null;
} catch {
return null;
}
}
getAllResponseHeaders() {
return [...this.#response?.headers.entries()??[]].map(([key,value]) => `${key}: ${value}\r\n`).join('');
}
get response() {
return this.#responseAny;
}
get responseText() {
if (this.#finalResponseType !== 'text' && this.#responseType !== '') {
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}').`, "InvalidStateError");
}
return this.#responseText;
}
get responseXML() {
if (this.#finalResponseType !== 'document' && this.#responseType !== '') {
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}').`, "InvalidStateError");
}
return this.#responseXML;
}
get responseURL() {
return this.#response?.url;
}
get status() {
return this.#status ?? this.#response?.status ?? 0;
}
get statusText() {
return this.#response?.statusText ?? '';
}
async #emitEvent(type) {
this.dispatchEvent(new ProgressEvent(type, {
lengthComputable: this.#dataLengthComputable,
loaded: this.#dataLoaded,
total: this.#dataTotal
}));
}
// I've got the perfect disguise..
get [Symbol.toStringTag]() {
return 'XMLHttpRequest';
}
static toString = ()=> 'function XMLHttpRequest() { [native code] }';
}
window.XMLHttpRequestEventTarget = XMLHttpRequestEventTarget;
window.XMLHttpRequestUpload = XMLHttpRequestUpload;
window.XMLHttpRequest = XMLHttpRequest;
window.fetch = fetch;
return middleMan;
})(globalThis.unsafeWindow ?? window);