- // ==UserScript==
- // @name Itsnotlupus' MiddleMan
- // @namespace Itsnotlupus Industries
- // @version 1.3
- // @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).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(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) {
- try {
- if (obj instanceof type) obj = await hook(obj.clone()) ?? obj;
- } catch (e) {
- console.error(`MiddleMan: Uncaught exception in ${name} hook!`, e);
- }
- }
- 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);
- }
-
- /**
- * 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(async ({listener, options}) => {
- if (immediateStop) return;
- if (options.once) this.removeEventListener(eventProxy.type, listener, options);
- try {
- listener.call(this, eventProxy);
- } catch (e) {
- // I think it's impossible to match EventTarget::dispatchEvent throwing behavior in pure JS. oh well. fudge the timing and keep on trucking.
- await 0;
- throw e;
- }
- });
- return true;
- }
- }
-
- class XMLHttpRequestUpload extends XMLHttpRequestEventTarget {
- constructor() {
- super(["loadstart","progress","abort","error","load","timeout","loadend"]);
- }
- }
-
- /**
- * 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://github.com/web-platform-tests/wpt '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;
-
- #dataLengthComputable = false;
- #dataLoaded = 0;
- #dataTotal = 0;
-
- #uploadEventTarget;
-
- #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.#mimeTypeOverride = 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.
- }
- async #trackUploadEvents() {
- let loaded =0, total = 0, hasSize = false, error = false;;
- const emitUploadEvent = type => {
- this.#uploadEventTarget.dispatchEvent(new ProgressEvent(type, {
- lengthComputable: hasSize,
- loaded,
- total
- }));
- }
- ["$abort", "$error", "$timeout"].forEach(type => {
- this.addEventListener(type, e => {
- error = true;
- hasSize = false;
- loaded = total = 0;
- emitUploadEvent(e.type.slice(1));
- emitUploadEvent("loadend");
- }, { once: true });
- });
- const blob = await new Response(this.#requestOptions.body).blob();
- total = blob.size;
- hasSize = total>0;
- this.#requestOptions.duplex = "half";
- this.#requestOptions.body = blob.stream().pipeThrough(new TransformStream({
- 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');
- }
- set withCredentials(flag) {
- if (this.#sendFlag) throw new DOMException("", "InvalidStateError");
- this.#requestOptions.credentials = flag ? "include" : "omit";
- }
- get withCredentials() {
- return this.#requestOptions.credentials == "include";
- }
- send(body = null) {
- this.#assertReadyState(1);
- if (this.#sendFlag) throw new DOMException("", "InvalidStateError");
- this.#sendFlag = true;
- if (this.#requestOptions.method != 'GET' && this.#requestOptions.method != 'HEAD') {
- 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");
- (async ()=> {
- if (this.#uploadEventTarget && this.#requestOptions.body) {
- // user asked for .upload, and the request has a body. track upload events.
- await this.#trackUploadEvents(this.#requestOptions);
- }
- let response;
- try {
- this.#response = await fetch(request, { signal });
- let finalResponseType = this.#responseType;
- this.#finalMimeType = this.#mimeTypeOverride ?? this.#response.headers.get('content-type')?.split(';')[0].trim() ?? 'text/xml'; // header parsing is still iffy
- if (!finalResponseType) {
- finalResponseType = ([ 'text/html', 'text/xml', 'application/xml'].includes(this.#finalMimeType) || this.#finalMimeType.endsWith("+xml")) ? 'document' : 'text';
- }
- this.#finalResponseType = finalResponseType;
- 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;
- }
- })();
- }
- /**
- * 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();
- 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.#emitEvent('$'+(this.#errorEvent ?? "error")); // XXX weird hack to get the upload events to fire first.
- 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 = await this.#response.blob();
- } catch {
- this.#responseAny = null;
- }
- break;
- case 'document': {
- this.#responseText = await this.#response.text();
- try {
- const parser = new DOMParser();
- const doc = parser.parseFromString(this.#responseText, this.#finalMimeType);
- this.#responseAny = this.#responseXML = doc;
- } 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 = await this.#response.text();
- 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 decoder = new TextDecoder();
-
- const reader = response.body.getReader();
- const handleChunk = ({ done, value }) => {
- if (done) return;
- this.#dataLoaded += value.length;
- if (isText) {
- this.#responseText += decoder.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 ?? '';
- }
-
- // event dispatching resiliency
- 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);