xhr and fetch hooks
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/575586/1809411/ajax-intercept.js
/*
* 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);