Itsnotlupus' MiddleMan

inspect/intercept/modify any network requests

Dieses Skript sollte nicht direkt installiert werden. Es handelt sich hier um eine Bibliothek für andere Skripte, welche über folgenden Befehl in den Metadaten eines Skriptes eingebunden wird // @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);