ajax-intercept

xhr and fetch hooks

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.greasyfork.org/scripts/575586/1809411/ajax-intercept.js

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
/*
 * fetch-intercept.js
 *
 * Quick start
 * 1. Load this file early.
 * 2. It installs proxies for `window.fetch` and `window.XMLHttpRequest`.
 * 3. Use `window.__ix.intercept(...)` to register hooks.
 *
 * Minimal example
 * const id = __ix.intercept('/api/', {
 *   request(req) {
 *     req.headers.set('x-debug', '1');
 *   },
 *   response(res) {
 *     if (res.json) res.json.intercepted = true;
 *   },
 *   readOnly: false,
 * });
 *
 * __ix.unintercept(id);
 *
 * Public API
 * - `intercept(pattern, opts) -> id`
 * - `unintercept(id) -> boolean`
 * - `clearAll()`
 * - `enable()`
 * - `disable()`
 * - `isEnabled() -> boolean`
 * - `nativeFetch`
 * - `NativeXHR`
 * - `listSkippedUrls() -> string[]`
 *
 * Pattern matching
 * - `null` / `undefined`: match everything
 * - `string`: substring match on URL
 * - `RegExp`: test URL
 * - function: `(ctx) => boolean`, where `ctx = { url, method, type }`
 *
 * Hook options
 * - `request(req)`
 * - `response(res, req)`
 * - `types: ['fetch'] | ['xhr'] | ['fetch', 'xhr']`
 * - `readOnly: boolean` for response hooks
 *
 * Request contexts
 * - Fetch: `{ type, url, method, headers, body, init }`
 * - XHR: `{ type, url, method, headers, body }`
 * - Returned request replacements must keep:
 *   - `url: string`
 *   - `method: string`
 *   - `headers` with `.forEach()`
 *
 * Fetch notes
 * - `fetch(new Request(...))` bodies are loaded lazily
 * - Call `await req.bodyBuffer()` if a hook needs buffered request bytes
 * - Setting `req.body` replaces the outgoing body
 *
 * Response contexts
 * - Fetch: `{ type, ok, status, statusText, headers, url, redirected, text, json, parseError }`
 * - XHR: `{ type, status, statusText, headers, url, text, json }`
 * - Binary XHR read-only paths may also include:
 *   - `responseType`
 *   - `response`
 *   - `readOnly: true`
 *
 * Important limits
 * - Install early for best XHR event interception
 * - Streaming fetch responses may be passed through
 * - Binary XHR responses are effectively read-only
 * - Synchronous XHR skips async request-hook interception
 * - Mutated fetch responses are synthesized and not fully native-equivalent
 * - Hook errors are caught and logged
 */

// ============================================================================
// userscript-interceptor.js
// ============================================================================

(function installInterceptor(global) {
  "use strict";

  if (global.__ix && global.__ix.__version === 1) {
    return global.__ix;
  }

  // -------------------------------------------------------------------------
  // 1. Capture native references immediately.
  // -------------------------------------------------------------------------
  const nativeFetch = global.fetch ? global.fetch.bind(global) : null;
  const NativeXHR = global.XMLHttpRequest;
  const NativeRequest = global.Request;
  const NativeHeaders = global.Headers;
  const NativeResponse = global.Response;

  const REQUEST_PASSTHROUGH_PROPS = [
    "mode",
    "credentials",
    "cache",
    "redirect",
    "referrer",
    "referrerPolicy",
    "integrity",
    "keepalive",
    "signal",
  ];

  const REQUEST_BODY_INIT_KEYS = [
    "body",
    "duplex",
  ];

  let enabled = true;

  // -------------------------------------------------------------------------
  // 2. Pattern matching.
  // -------------------------------------------------------------------------
  function matches(pattern, ctx) {
    if (pattern == null) return true;
    if (typeof pattern === "string") return ctx.url.indexOf(pattern) !== -1;
    if (pattern instanceof RegExp) return pattern.test(ctx.url);
    if (typeof pattern === "function") {
      try {
        return !!pattern(ctx);
      } catch (e) {
        console.error("[ix] pattern function threw:", e);
        return false;
      }
    }
    return false;
  }

  // -------------------------------------------------------------------------
  // 3. Hook registry.
  // -------------------------------------------------------------------------
  const hooks = [];
  let nextHookId = 1;

  function intercept(pattern, opts) {
    if (
      !opts ||
      (typeof opts.request !== "function" &&
        typeof opts.response !== "function")
    ) {
      throw new TypeError(
        "[ix] intercept() needs { request?, response? } with at least one function",
      );
    }
    const types = new Set();
    if (opts.types) {
      for (const t of opts.types) types.add(t);
    } else {
      types.add("xhr");
      types.add("fetch");
    }
    const hook = {
      id: nextHookId++,
      pattern,
      request: typeof opts.request === "function" ? opts.request : null,
      response: typeof opts.response === "function" ? opts.response : null,
      types,
      readOnly: typeof opts.response !== "undefined" ? !!opts.readOnly : true,
    };
    hooks.push(hook);
    return hook.id;
  }

  function unintercept(id) {
    const i = hooks.findIndex((h) => h.id === id);
    if (i >= 0) {
      hooks.splice(i, 1);
      return true;
    }
    return false;
  }

  function clearAll() {
    hooks.length = 0;
  }

  const WARNED_STREAM_URL_LIMIT = 200;
  const warnedStreamUrls = new Set();
  function rememberWarnedStreamKey(key) {
    if (warnedStreamUrls.has(key)) return;
    warnedStreamUrls.add(key);
    if (warnedStreamUrls.size > WARNED_STREAM_URL_LIMIT) {
      const oldestKey = warnedStreamUrls.values().next().value;
      warnedStreamUrls.delete(oldestKey);
    }
  }

  function warnStreamOnce(url, reason) {
    let key = url;
    try {
      const u = new URL(url, global.location.href);
      key = u.origin + u.pathname;
    } catch (_) {}
    if (warnedStreamUrls.has(key)) return;
    rememberWarnedStreamKey(key);
    console.warn(
      "[ix] streamed response detected; passing through unmodified.\n" +
        "     url: " +
        url +
        "\n" +
        "     reason: " +
        reason +
        "\n" +
        "     Consider excluding this URL from your intercept patterns.",
    );
  }

  // -------------------------------------------------------------------------
  // 4. Hook chain runners.
  // -------------------------------------------------------------------------
  async function runRequestChain(req, type) {
    let current = req;
    for (const h of hooks) {
      if (!h.request) continue;
      if (!h.types.has(type)) continue;
      if (
        !matches(h.pattern, { url: current.url, method: current.method, type })
      )
        continue;
      try {
        const out = await h.request(current);
        if (out !== undefined) {
          if (isValidRequestContext(out)) {
            current = out;
          } else {
            console.error(
              "[ix] request hook",
              h.id,
              "returned invalid request context; ignoring result.",
            );
          }
        }
      } catch (e) {
        console.error("[ix] request hook", h.id, "threw:", e);
      }
    }
    return current;
  }

  function isValidRequestContext(ctx) {
    return !!(
      ctx &&
      typeof ctx.url === "string" &&
      typeof ctx.method === "string" &&
      ctx.headers &&
      typeof ctx.headers.forEach === "function"
    );
  }

  async function runResponseChain(res, req, type) {
    let current = res;
    for (const h of hooks) {
      if (!h.response) continue;
      if (!h.types.has(type)) continue;
      if (!matches(h.pattern, { url: req.url, method: req.method, type }))
        continue;
      try {
        const out = await h.response(current, req);
        if (out !== undefined) current = out;
      } catch (e) {
        console.error("[ix] response hook", h.id, "threw:", e);
      }
    }
    return current;
  }

  // -------------------------------------------------------------------------
  // 5. Streaming detection.
  // -------------------------------------------------------------------------
  function detectStreaming(response) {
    const ctype = (response.headers.get("content-type") || "").toLowerCase();
    if (ctype.indexOf("text/event-stream") !== -1)
      return "content-type: text/event-stream";
    if (ctype.indexOf("ndjson") !== -1)
      return "content-type: application/x-ndjson";
    if (ctype.indexOf("application/octet-stream") !== -1)
      return "content-type: application/octet-stream";
    if (ctype.indexOf("multipart/") !== -1) return "content-type: multipart/*";
    const te = (response.headers.get("transfer-encoding") || "").toLowerCase();
    if (te.indexOf("chunked") !== -1) {
      const safeText =
        ctype.indexOf("json") !== -1 ||
        ctype.indexOf("text/") !== -1 ||
        ctype.indexOf("xml") !== -1 ||
        ctype.indexOf("javascript") !== -1 ||
        ctype.indexOf("html") !== -1;
      if (!safeText) return "transfer-encoding: chunked, non-text content-type";
    }
    return null;
  }

  // -------------------------------------------------------------------------
  // 6. fetch() interception
  // -------------------------------------------------------------------------
  function hasOwn(obj, key) {
    return !!obj && Object.prototype.hasOwnProperty.call(obj, key);
  }

  function buildFetchInit(baseInit, reqCtx, includeBody) {
    const finalInit = Object.assign({}, baseInit, reqCtx.init, {
      method: reqCtx.method,
      headers: reqCtx.headers,
    });
    if (includeBody) {
      finalInit.body = reqCtx.body;
    } else {
      delete finalInit.body;
    }
    return finalInit;
  }

  async function fetchProxy(input, init) {
    if (!nativeFetch) throw new Error("[ix] native fetch unavailable");
    if (!enabled || hooks.length === 0) return nativeFetch(input, init);

    let url,
      method,
      headers,
      body,
      originalBody,
      originalRequest = null;
    const passthrough = {};
    let requestBodyLoader = null;
    const baseInit = {};
    let fetchInitBase = {};
    const initObj = init || {};
    let canReuseOriginalRequest = false;
    let requestInitOverridesBody = false;

    if (NativeRequest && input instanceof NativeRequest) {
      url = input.url;
      method = input.method;
      headers = new NativeHeaders(input.headers);
      for (const k of REQUEST_PASSTHROUGH_PROPS) {
        try {
          passthrough[k] = input[k];
        } catch (_) {}
      }
      for (const key of REQUEST_BODY_INIT_KEYS) {
        if (hasOwn(initObj, key)) {
          requestInitOverridesBody = true;
          break;
        }
      }
      for (const key in initObj) {
        if (!hasOwn(initObj, key)) continue;
        if (key === "headers" || key === "method") continue;
        if (key === "body") continue;
        baseInit[key] = initObj[key];
      }
      body = undefined;
      requestBodyLoader = async () => {
        try {
          const loaded = await input.clone().arrayBuffer();
          return loaded && loaded.byteLength === 0 ? null : loaded;
        } catch (_) {
          return null;
        }
      };
      originalRequest = input;
      originalBody = undefined;
      fetchInitBase = Object.assign({}, passthrough, baseInit);
      canReuseOriginalRequest = Object.keys(baseInit).length === 0;
    } else {
      url = String(input);
      method = (init && init.method) || "GET";
      headers = new NativeHeaders((init && init.headers) || undefined);
      body = init ? init.body : undefined;
      originalBody = body;
      Object.assign(baseInit, initObj);
      fetchInitBase = baseInit;
    }

    const bodySnapshot = body;
    const reqCtx = {
      type: "fetch",
      url,
      method,
      headers,
      init: init || {},
    };
    if (requestBodyLoader) {
      Object.defineProperty(reqCtx, "body", {
        configurable: true,
        enumerable: true,
        get() {
          return body;
        },
        set(value) {
          body = value;
        },
      });
      Object.defineProperty(reqCtx, "bodyBuffer", {
        configurable: true,
        enumerable: true,
        value: async () => {
          if (body === undefined) body = await requestBodyLoader();
          return body;
        },
      });
    } else {
      reqCtx.body = body;
    }
    const finalReq = await runRequestChain(reqCtx, "fetch");

    const isBodyless = finalReq.method === "GET" || finalReq.method === "HEAD";
    const bodyTouched = finalReq.body !== bodySnapshot;
    const methodChanged = finalReq.method !== method;
    const urlChanged = finalReq.url !== url;
    const requestHeadersChanged = finalReq.headers !== headers;
    const initChanged = finalReq.init !== initObj;

    let finalInput = finalReq.url;
    let finalInit;

    if (isBodyless) {
      finalInit = buildFetchInit(fetchInitBase, finalReq, false);
    } else if (bodyTouched) {
      finalInit = buildFetchInit(fetchInitBase, finalReq, true);
    } else if (NativeRequest && originalRequest instanceof NativeRequest) {
      const needsRebuild =
        requestInitOverridesBody ||
        methodChanged ||
        urlChanged ||
        requestHeadersChanged ||
        initChanged ||
        !canReuseOriginalRequest;

      if (!needsRebuild) {
        finalInput = originalRequest;
        finalInit = undefined;
      } else if (requestInitOverridesBody) {
        finalInit = buildFetchInit(fetchInitBase, finalReq, true);
      } else {
        finalInit = buildFetchInit(fetchInitBase, finalReq, false);
      }
    } else if (originalBody !== undefined) {
      finalInit = buildFetchInit(fetchInitBase, finalReq, true);
    } else {
      finalInit = buildFetchInit(fetchInitBase, finalReq, false);
    }

    let response;
    try {
      response =
        finalInit === undefined
          ? await nativeFetch(finalInput)
          : await nativeFetch(finalInput, finalInit);
    } catch (networkErr) {
      const errCtx = {
        type: "fetch",
        ok: false,
        status: 0,
        statusText: "Network error",
        headers: new NativeHeaders(),
        url: finalReq.url,
        error: networkErr,
        body: null,
        json: null,
        text: null,
      };
      const out = await runResponseChain(errCtx, finalReq, "fetch");
      if (out && out.error === networkErr) throw networkErr;
      return synthesizeFetchResponse(out);
    }

    const matchingHooks = hooks.filter(
      (h) =>
        h.response &&
        h.types.has("fetch") &&
        matches(h.pattern, {
          url: finalReq.url,
          method: finalReq.method,
          type: "fetch",
        }),
    );

    if (matchingHooks.length === 0) return response;

    const hasMutatingHook = matchingHooks.some((h) => !h.readOnly);

    const streamReason = detectStreaming(response);
    if (streamReason) {
      warnStreamOnce(finalReq.url, streamReason);
      return response;
    }

    // --- READ-ONLY FAST PATH ---
    if (!hasMutatingHook) {
      const resCtx = {
        type: "fetch",
        ok: response.ok,
        status: response.status,
        statusText: response.statusText,
        headers: new NativeHeaders(response.headers),
        url: response.url,
        redirected: response.redirected,

        // No body parsing
        text: null,
        json: null,
        parseError: null,

        // Optional raw access (advanced hooks can use it)
        response,
      };

      // Still run hooks (for logging, metrics, triggers, etc.)
      await runResponseChain(resCtx, finalReq, "fetch");

      return response;
    }

    // --- MUTATING PATH (existing behavior) ---
    const cloned = response.clone();
    let text = null,
      json = null,
      parseErr = null;

    try {
      text = await cloned.text();
      if (text) {
        const ctype = (
          response.headers.get("content-type") || ""
        ).toLowerCase();
        if (ctype.indexOf("json") !== -1) {
          try {
            json = JSON.parse(text);
          } catch (_) {}
        }
      }
    } catch (e) {
      parseErr = e;
    }

    const textSnapshot = text;
    const jsonSnapshot = json;

    const resCtx = {
      type: "fetch",
      ok: response.ok,
      status: response.status,
      statusText: response.statusText,
      headers: new NativeHeaders(response.headers),
      url: response.url,
      redirected: response.redirected,
      text,
      json,
      parseError: parseErr,
    };
    const headersSnapshot = Array.from(resCtx.headers.entries());

    const finalRes = await runResponseChain(resCtx, finalReq, "fetch");

    const jsonChanged = finalRes.json !== jsonSnapshot;
    const textChanged = finalRes.text !== textSnapshot;
    const finalHeadersSnapshot = Array.from(
      (finalRes.headers || new NativeHeaders()).entries(),
    );
    const headersChanged =
      headersSnapshot.length !== finalHeadersSnapshot.length ||
      headersSnapshot.some(
        ([name, value], i) =>
          name !== finalHeadersSnapshot[i][0] ||
          value !== finalHeadersSnapshot[i][1],
      );
    const statusChanged =
      finalRes.status !== resCtx.status ||
      finalRes.statusText !== resCtx.statusText;

    if (!jsonChanged && !textChanged && !headersChanged && !statusChanged) {
      return response;
    }

    return synthesizeFetchResponse(finalRes);
  }

  function synthesizeFetchResponse(ctx) {
    let bodyText;
    if (ctx.json !== null && ctx.json !== undefined) {
      try {
        bodyText = JSON.stringify(ctx.json);
      } catch (_) {
        bodyText = ctx.text || "";
      }
    } else {
      bodyText = ctx.text == null ? "" : ctx.text;
    }
    const status = ctx.status ?? 200;
    if (status < 200 || status > 599) {
      throw new RangeError(
        "[ix] synthetic fetch response needs status 200-599",
      );
    }
    return new NativeResponse(bodyText, {
      status,
      statusText: ctx.statusText || "",
      headers: ctx.headers || new NativeHeaders(),
    });
  }

  if (!nativeFetch) {
    throw new Error("[ix] native fetch unavailable");
  }
  global.fetch = fetchProxy;
  // -------------------------------------------------------------------------
  // 7. XMLHttpRequest interception
  //    Strategy: subclass-style proxy (return native xhr from constructor),
  //    but suppress the page's view of readystatechange(4)/load until
  //    response hooks finish. We do this by intercepting those events
  //    before the page sees them and re-dispatching after mutation.
  // -------------------------------------------------------------------------
  function ProxyXHR() {
    const xhr = new NativeXHR();

    const state = {
      method: "GET",
      url: "",
      async: true,
      openArgs: null,
      requestHeaders: new NativeHeaders(),
      body: null,
      intercepting: false,
    };

    const origOpen = xhr.open;
    xhr.open = function (method, url) {
      state.method = String(method || "GET").toUpperCase();
      state.url = String(url);
      state.async = arguments.length < 3 ? true : !!arguments[2];
      state.openArgs = Array.prototype.slice.call(arguments);
      state.requestHeaders = new NativeHeaders();
      state.intercepting = enabled && hooks.some((h) => h.types.has("xhr"));
      return origOpen.apply(xhr, arguments);
    };

    const origSetRequestHeader = xhr.setRequestHeader;
    xhr.setRequestHeader = function (name, value) {
      // Native XHR semantics: repeated set calls combine values with ", ".
      // Use append() so the Headers object we pass to hooks reflects what
      // will actually go on the wire.
      const ret = origSetRequestHeader.call(xhr, name, value);
      try {
        state.requestHeaders.append(name, value);
      } catch (_) {}
      return ret;
    };

    const origSend = xhr.send;
    xhr.send = function (body) {
      state.body = body == null ? null : body;

      if (!state.intercepting) {
        return origSend.call(xhr, body);
      }

      if (!state.async) {
        const key = "__sync_xhr__" + state.url;
        if (!warnedStreamUrls.has(key)) {
          rememberWarnedStreamKey(key);
          console.warn(
            "[ix] synchronous XHR detected; request hooks skipped (cannot await).\n" +
              "     url: " +
              state.url +
              "\n" +
              "     Response hooks will still run synchronously.",
          );
        }
        installResponseInterception(xhr, state, {
          url: state.url,
          method: state.method,
          headers: state.requestHeaders,
          body: state.body,
          type: "xhr",
        });
        return origSend.call(xhr, body);
      }

      const reqCtx = {
        type: "xhr",
        url: state.url,
        method: state.method,
        headers: state.requestHeaders,
        body: state.body,
      };

      Promise.resolve(runRequestChain(reqCtx, "xhr"))
        .then((finalReq) => {
          const reopened =
            finalReq.url !== state.url || finalReq.method !== state.method;
          if (reopened) {
            const openArgs = state.openArgs ? state.openArgs.slice() : [];
            openArgs[0] = finalReq.method;
            openArgs[1] = finalReq.url;
            origOpen.apply(xhr, openArgs);
          }
          try {
            if (reopened) {
              finalReq.headers.forEach((value, name) => {
                origSetRequestHeader.call(xhr, name, value);
              });
            } else if (finalReq.headers !== state.requestHeaders) {
              console.warn(
                "[ix] XHR request hook replaced headers without changing method/url; native XHR headers were already committed, so header mutations were skipped.",
              );
            }
          } catch (_) {}
          installResponseInterception(xhr, state, finalReq);
          try {
            origSend.call(xhr, finalReq.body == null ? null : finalReq.body);
          } catch (e) {
            console.error("[ix] xhr.send threw after request hook:", e);
          }
        })
        .catch((e) => {
          console.error("[ix] request chain failed, sending unmodified:", e);
          origSend.call(xhr, body);
        });
    };

    return xhr;
  }

  function installResponseInterception(xhr, state, finalReq) {
    let mutationDone = false;
    let mutatedText = null;

    // -----------------------------------------------------------------------
    // Step A. Intercept readystatechange and load BEFORE the page sees them.
    // We do this with a captured listener registered as early as possible.
    // For state < 4 we let events pass through unchanged. For state 4 we
    // capture, run the response chain, then re-dispatch the events the page
    // is waiting for.
    //
    // Important detail: the standard says listeners run in registration
    // order. We register here, inside the same Promise.then microtask that
    // installs us, which in practice runs before the page's send() completes
    // its synchronous tail. For the remaining race window (page registered
    // a readystatechange listener BEFORE any of our code ran), the accessor
    // override is the safety net — page reads from xhr.responseText etc.
    // see the mutated values regardless of which listener fired first.
    // -----------------------------------------------------------------------

    const origAddEventListener = xhr.addEventListener;
    const origRemoveEventListener = xhr.removeEventListener;

    // Page-registered listeners we've intercepted, so we can call them after
    // mutation. Keyed by event name.
    const pageListeners = { readystatechange: [], load: [], loadend: [] };

    // Wrap the page's addEventListener so we can capture its listeners for
    // readystatechange/load/loadend rather than letting them register
    // natively (where they'd fire before we mutate).
    xhr.addEventListener = function (type, listener, opts) {
      if (
        type === "readystatechange" ||
        type === "load" ||
        type === "loadend"
      ) {
        pageListeners[type].push(listener);
        return;
      }
      return origAddEventListener.call(xhr, type, listener, opts);
    };
    xhr.removeEventListener = function (type, listener, opts) {
      if (
        type === "readystatechange" ||
        type === "load" ||
        type === "loadend"
      ) {
        const i = pageListeners[type].indexOf(listener);
        if (i >= 0) pageListeners[type].splice(i, 1);
        return;
      }
      return origRemoveEventListener.call(xhr, type, listener, opts);
    };

    // Likewise for the property-slot handlers (onreadystatechange etc).
    // We replace them with no-ops on the native object and store the page's
    // assigned function in our own slot.
    const slot = { onreadystatechange: null, onload: null, onloadend: null };
    ["onreadystatechange", "onload", "onloadend"].forEach((prop) => {
      try {
        Object.defineProperty(xhr, prop, {
          configurable: true,
          get: () => slot[prop],
          set: (fn) => {
            slot[prop] = typeof fn === "function" ? fn : null;
          },
        });
      } catch (_) {}
    });

    // -----------------------------------------------------------------------
    // Step B. Native listener that drives the response pipeline.
    // -----------------------------------------------------------------------
    function onNativeReadyStateChange(evt) {
      if (xhr.readyState < 4) {
        // Pass through immediately for states 1-3.
        dispatchToPage("readystatechange", evt);
        return;
      }
      // State 4: hold all "done" events until the response chain finishes.
      if (mutationDone) {
        // Already processed; this is a re-fire (shouldn't normally happen).
        return;
      }
      runMutation().then(() => {
        mutationDone = true;
        restoreEventOverrides();
        // Fire the deferred state-4 readystatechange first, then load,
        // then loadend, in spec order.
        dispatchToPage("readystatechange", evt);
        dispatchToPage("load", { type: "load" });
        dispatchToPage("loadend", { type: "loadend" });
      });
    }

    function dispatchToPage(type, evt) {
      // Property-slot handler first (matches native behavior).
      const slotFn = slot["on" + type];
      if (typeof slotFn === "function") {
        try {
          slotFn.call(xhr, evt);
        } catch (e) {
          console.error("[ix] page on" + type + " threw:", e);
        }
      }
      const list = pageListeners[type] || [];
      for (const fn of list.slice()) {
        try {
          fn.call(xhr, evt);
        } catch (e) {
          console.error("[ix] page " + type + " listener threw:", e);
        }
      }
    }

    // We register the native listener via the *original* addEventListener
    // because we just shadowed the public one above.
    origAddEventListener.call(
      xhr,
      "readystatechange",
      onNativeReadyStateChange,
    );
    // Suppress native load/loadend — onNativeReadyStateChange handles them.
    origAddEventListener.call(xhr, "load", stopProp);
    origAddEventListener.call(xhr, "loadend", stopProp);
    function stopProp(e) {
      /* swallow; we will re-dispatch after mutation */
    }

    function restoreEventOverrides() {
      xhr.addEventListener = origAddEventListener;
      xhr.removeEventListener = origRemoveEventListener;
    }

    // -----------------------------------------------------------------------
    // Step C. Run the response hook chain and apply mutations.
    // -----------------------------------------------------------------------
    async function runMutation() {
      const anyResponseHook = hooks.some(
        (h) =>
          h.response &&
          h.types.has("xhr") &&
          matches(h.pattern, {
            url: finalReq.url,
            method: finalReq.method,
            type: "xhr",
          }),
      );
      if (!anyResponseHook) return;

      const rt = xhr.responseType;
      // Defensive reads — some browsers throw on aborted XHRs.
      let rawStatus = 0,
        rawStatusText = "";
      try {
        rawStatus = xhr.status;
      } catch (_) {}
      try {
        rawStatusText = xhr.statusText;
      } catch (_) {}

      if (rt && rt !== "text" && rt !== "json") {
        const key = "__binary_xhr__" + finalReq.url + "#" + rt;
        if (!warnedStreamUrls.has(key)) {
          rememberWarnedStreamKey(key);
          console.warn(
            '[ix] XHR with responseType="' +
              rt +
              '" matched a hook; mutation skipped.\n' +
              "     url: " +
              finalReq.url +
              "\n" +
              "     Read-only inspection still possible via res.response in your hook;\n" +
              "     mutations to res.text/res.json will be ignored.",
          );
        }
        const headersRO = parseHeaderString(xhr.getAllResponseHeaders() || "");
        const resCtxRO = {
          type: "xhr",
          status: rawStatus,
          statusText: rawStatusText,
          headers: headersRO,
          url: xhr.responseURL || finalReq.url,
          text: null,
          json: null,
          responseType: rt,
          response: safeGet(() => xhr.response),
          readOnly: true,
        };
        for (const h of hooks) {
          if (!h.response) continue;
          if (!h.types.has("xhr")) continue;
          if (
            !matches(h.pattern, {
              url: finalReq.url,
              method: finalReq.method,
              type: "xhr",
            })
          )
            continue;
          try {
            h.response(resCtxRO, finalReq);
          } catch (e) {
            console.error("[ix] xhr response hook threw (read-only path):", e);
          }
        }
        return;
      }

      let text = null;
      try {
        text = xhr.responseText;
      } catch (_) {
        text = null;
      }

      let json = null;
      if (text) {
        const ctype = (
          xhr.getResponseHeader("content-type") || ""
        ).toLowerCase();
        if (ctype.indexOf("json") !== -1) {
          try {
            json = JSON.parse(text);
          } catch (_) {}
        }
      }

      const headers = parseHeaderString(xhr.getAllResponseHeaders() || "");
      const resCtx = {
        type: "xhr",
        status: rawStatus,
        statusText: rawStatusText,
        headers,
        url: xhr.responseURL || finalReq.url,
        text,
        json,
      };
      const textSnapshot = text;
      const jsonSnapshot = json;

      let finalRes = resCtx;
      // Synchronous chain so we don't yield more microtasks than necessary.
      // (We're already in an async function, so the outer await can wait.)
      for (const h of hooks) {
        if (!h.response) continue;
        if (!h.types.has("xhr")) continue;
        if (
          !matches(h.pattern, {
            url: finalReq.url,
            method: finalReq.method,
            type: "xhr",
          })
        )
          continue;
        try {
          const out = h.response(finalRes, finalReq);
          if (out && typeof out.then === "function") {
            // Now that we defer state-4 dispatch, we CAN await async hooks
            // for XHR. This is a v3 capability the previous version had to
            // refuse. Page listeners won't fire until we resolve.
            try {
              const awaited = await out;
              if (awaited !== undefined) finalRes = awaited;
            } catch (e) {
              console.error("[ix] xhr response hook (async) threw:", e);
            }
            continue;
          }
          if (out !== undefined) finalRes = out;
        } catch (e) {
          console.error("[ix] xhr response hook threw:", e);
        }
      }

      const jsonChanged = finalRes.json !== jsonSnapshot;
      const textChanged = finalRes.text !== textSnapshot;

      if (jsonChanged) {
        try {
          mutatedText = JSON.stringify(finalRes.json);
        } catch (_) {
          mutatedText = finalRes.text;
        }
      } else if (textChanged) {
        mutatedText = finalRes.text;
      }

      if (mutatedText != null) {
        try {
          Object.defineProperty(xhr, "responseText", {
            configurable: true,
            get: () => mutatedText,
          });
          Object.defineProperty(xhr, "response", {
            configurable: true,
            get: () => {
              if (xhr.responseType === "" || xhr.responseType === "text")
                return mutatedText;
              if (xhr.responseType === "json") {
                try {
                  return JSON.parse(mutatedText);
                } catch (_) {
                  return null;
                }
              }
              return mutatedText;
            },
          });
        } catch (e) {
          console.warn("[ix] could not override xhr.responseText:", e);
        }
      }

      if (finalRes.status !== resCtx.status) {
        try {
          Object.defineProperty(xhr, "status", {
            configurable: true,
            get: () => finalRes.status,
          });
        } catch (_) {}
      }
      if (finalRes.statusText !== resCtx.statusText) {
        try {
          Object.defineProperty(xhr, "statusText", {
            configurable: true,
            get: () => finalRes.statusText,
          });
        } catch (_) {}
      }
    }
  }

  function parseHeaderString(raw) {
    const headers = new NativeHeaders();
    raw
      .trim()
      .split(/[\r\n]+/)
      .forEach((line) => {
        const idx = line.indexOf(":");
        if (idx > 0) {
          try {
            headers.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
          } catch (_) {}
        }
      });
    return headers;
  }

  function safeGet(fn) {
    try {
      return fn();
    } catch (_) {
      return null;
    }
  }

  ["UNSENT", "OPENED", "HEADERS_RECEIVED", "LOADING", "DONE"].forEach((k) => {
    if (k in NativeXHR) ProxyXHR[k] = NativeXHR[k];
  });

  global.XMLHttpRequest = ProxyXHR;

  // -------------------------------------------------------------------------
  // 8. Public API
  // -------------------------------------------------------------------------
  const api = {
    __version: 3,
    intercept,
    unintercept,
    clearAll,
    enable: () => {
      enabled = true;
    },
    disable: () => {
      enabled = false;
    },
    isEnabled: () => enabled,
    nativeFetch,
    NativeXHR,
    listSkippedUrls: () => Array.from(warnedStreamUrls),
  };

  global.__ix = api;
  return api;
})(typeof unsafeWindow !== "undefined" ? unsafeWindow : window);