// ==UserScript==
// @name Itsnotlupus' MiddleMan
// @namespace Itsnotlupus Industries
// @version 1.0.1
// @description inspect/intercept/modify any network requests
// @author Itsnotlupus
// @license MIT
// ==/UserScript==
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) :
['^', ...path.split(/([*])/).map((chunk, i) => i%2==0 ? chunk.replace(/([^a-zA-Z0-9])/g, "\\$1") : '.'+chunk), '$'];
return new RegExp(r.join(''));
}
/**
* Call this with a Request or a Response, and it'll loop through
* each relevant hook to inspect and/or transform it.
*/
async process(obj) {
const { constructor: type, constructor: { name } } = obj;
const routes = this.routes[name], hooks = [];
Object.keys(routes).forEach(k => {
if (obj.url.match(this.regexps[k])) hooks.push(...routes[k]);
});
for (const hook of hooks) {
if (obj instanceof type) obj = await hook(obj.clone()) ?? obj;
}
return obj;
}
}
// 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);
const response = result instanceof Request ? await _fetch(result) : result;
return middleMan.process(response);
}
/**
* An XMLHttpRequest polyfill written on top of fetch().
* Nothing special here, except that means XHR can get hooked through middleMan too.
*
* A few gotchas:
* - This is not spec-compliant. In many ways. https://xhr.spec.whatwg.org/
* - xhr.upload is not implemented. we'll throw an exception if someone tries to use it.
* - that "extends EventTarget" line below used to be a non-starter on Safari. Perhaps it still is.
* - no test coverage. But I tried it on 2 sites and it didn't explode, so.. pretty good.
*/
class XMLHttpRequest extends EventTarget {
#readyState;
#requestOptions;
#requestURL;
#abortController;
#timeout;
#responseType;
#mimeTypeOverride;
#response;
#responseText;
#responseXML;
#responseAny;
#dataLengthComputable = false;
#dataLoaded = 0;
#dataTotal = 0;
#errorEvent;
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();
this.#readyState = 0;
}
get readyState() {
return this.#readyState;
}
#assertReadyState(...validValues) {
if (!validValues.includes(this.#readyState)) {
throw new Error("Failed to take action on XMLHttpRequest: Invalid state.");
}
}
#updateReadyState(value) {
this.#readyState = value;
this.#emitEvent("readystatechange");
}
// Request setup
open(method, url, async, user, password) {
this.#assertReadyState(0,1);
this.#requestOptions = {
method: method.toString().toUpperCase(),
headers: new Headers()
};
this.#requestURL = url;
this.#abortController = null;
this.#timeout = 0;
this.#responseType = '';
this.#mimeTypeOverride = null;
this.#response = null;
this.#responseText = '';
this.#responseAny = '';
this.#responseXML = null;
this.#dataLengthComputable = false;
this.#dataLoaded = 0;
this.#dataTotal = 0;
if (async === false) {
throw new Error("Synchronous XHR is not supported.");
}
if (user || password) {
this.#requestOptions.headers.set('Authorization', 'Basic '+btoa(`${user??''}:${password??''}`));
}
this.#updateReadyState(1);
}
setRequestHeader(header, value) {
this.#assertReadyState(1);
this.#requestOptions.headers.set(header, value);
}
overrideMimeType(mimeType) {
this.#mimeTypeOverride = mimeType;
}
set responseType(type) {
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() {
throw new Error("XMLHttpRequestUpload is not implemented.");
}
set withCredentials(flag) {
this.#requestOptions.credentials = flag ? "include" : "omit";
}
get withCredentials() {
return this.#requestOptions.credentials !== "omit"; // "same-origin" returns true here. whatever.
}
async send(body = null) {
this.#assertReadyState(1);
this.#requestOptions.body = body instanceof Document ? body.documentElement.outerHTML : body;
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");
let response;
try {
response = await fetch(request, { signal });
} catch (e) {
return this.#error();
}
this.#dataTotal = response.headers.get('content-length') ?? 0;
this.#dataLengthComputable = this.#dataTotal !== 0;
this.#updateReadyState(2);
this.#processResponse(response);
}
abort() {
this.#abortController?.abort();
this.#errorEvent = "abort";
}
#timedOut() {
this.#abortController?.abort();
this.#errorEvent = "timeout";
}
#error() {
// abort and timeout end up here.
this.#response = new Response('');
this.#updateReadyState(4);
this.#emitEvent(this.#errorEvent ?? "error");
this.#emitEvent("loadend");
this.#errorEvent = null;
}
async #processResponse(response) {
this.#response = response;
// TODO: remove all the clone() calls, I probably don't need them.
// ok, maybe one clone, if we start using body.getReader() to track downloads and emit meaningful "progress" events. TODO LATER
const progressResponse = response.clone();
const { size } = await progressResponse.blob();
this.#dataLoaded = size;
this.#emitEvent('progress');
switch (this.#responseType) {
case 'arraybuffer':
try {
this.#responseAny = await response.clone().arrayBuffer();
} catch {
this.#responseAny = null;
}
break;
case 'blob':
try {
this.#responseAny = await response.clone().blob();
} catch {
this.#responseAny = null;
}
break;
case 'document': {
this.#responseText = await response.clone().text();
const mimeType = this.#mimeTypeOverride ?? this.#response.headers.get('content-type')?.split(';')[0].trim() ?? 'text/xml';
try {
const parser = new DOMParser();
const doc = parser.parseFromString(this.#responseText, mimeType);
this.#responseAny = this.#responseXML = doc;
} catch {
this.#responseAny = null;
}
break;
}
case 'json':
try {
this.#responseAny = await response.clone().json();
} catch {
this.#responseAny = null;
}
break;
case '':
case 'text':
default:
this.#responseAny = this.#responseText = await response.clone().text();
break;
}
this.#updateReadyState(4);
this.#emitEvent("load");
this.#emitEvent("loadend");
}
// Response access
getResponseHeader(header) {
return this.#response?.headers.get(header) ?? null;
}
getAllResponseHeaders() {
return [...this.#response?.headers.entries()??[]].map(([key,value]) => `${key}: ${value}\r\n`).join('');
}
get response() {
return this.#responseAny;
}
get responseText() {
return this.#responseText;
}
get responseXML() {
return this.#responseXML;
}
get responseURL() {
return this.#response?.url;
}
get status() {
return this.#response?.status ?? 0;
}
get statusText() {
return this.#response?.statusText ?? '';
}
// event dispatching resiliency
async #emitEvent(name) {
try {
this.dispatchEvent(new ProgressEvent(name, {
lengthComputable: this.#dataLengthComputable,
loaded: this.#dataLoaded,
total: this.#dataTotal
}));
} catch (e) {
await 0;
throw e;
}
}
// informal event handlers
#events = {};
#setEvent(name, f) {
if (this.#events[name]) this.removeEventListener(name, this.#events[name]);
this.#events[name] = f;
this.addEventListener(name, this.#events[name]);
}
#getEvent(name) {
return this.#events[name];
}
set onabort(f) { this.#setEvent('abort', f); }
get onabort() { return this.#getEvent('abort'); }
set onerror(f) { this.#setEvent('error', f); }
get onerror() { return this.#getEvent('error'); }
set onload(f) { this.#setEvent('load', f); }
get onload() { return this.#getEvent('load'); }
set onloadend(f) { this.#setEvent('loadend', f); }
get onloadend() { this.#getEvent('loadend'); }
set onloadstart(f) { this.#setEvent('loadstart', f); }
get onloadstart() { this.#getEvent('loadstart'); }
set onprogress(f) { this.#setEvent('progress', f); }
get onprogress() { this.#getEvent('progress'); }
set onreadystatechange(f) { this.#setEvent('readystatechange', f); }
get onreadystatechange() { this.#getEvent('readystatechange'); }
set ontimeout(f) { this.#setEvent('timeout', f); }
get ontimeout() { this.#getEvent('timeout'); }
// I've got the perfect disguise..
get [Symbol.toStringTag]() {
return 'XMLHttpRequest';
}
static toString = ()=> 'function XMLHttpRequest() { [native code] }';
}
window.XMLHttpRequest = XMLHttpRequest;
window.fetch = fetch;
return middleMan;
})(globalThis.unsafeWindow ?? window);