playwright-ts/tools/ajaxHooker.js

624 lines
24 KiB
JavaScript

var ajaxHooker = (function () {
"use strict";
const win = window.unsafeWindow || document.defaultView || window;
win.apiCounter = 0;
win.lastResponseEndTime = 0;
const toString = Object.prototype.toString;
const getDescriptor = Object.getOwnPropertyDescriptor;
const hookFns = [];
const realXhr = win.XMLHttpRequest;
const realFetch = win.fetch;
const resProto = win.Response.prototype;
const xhrResponses = ["response", "responseText", "responseXML"];
const fetchResponses = ["arrayBuffer", "blob", "formData", "json", "text"];
const fetchInitProps = [
"method",
"headers",
"body",
"mode",
"credentials",
"cache",
"redirect",
"referrer",
"referrerPolicy",
"integrity",
"keepalive",
"signal",
"priority",
];
const xhrAsyncEvents = ["readystatechange", "load", "loadend"];
let filter;
function emptyFn() {}
function errorFn(err) {
console.error(err);
}
function defineProp(obj, prop, getter, setter) {
Object.defineProperty(obj, prop, {
configurable: true,
enumerable: true,
get: getter,
set: setter,
});
}
function readonly(obj, prop, value = obj[prop]) {
defineProp(obj, prop, () => value, emptyFn);
}
function writable(obj, prop, value = obj[prop]) {
Object.defineProperty(obj, prop, {
configurable: true,
enumerable: true,
writable: true,
value: value,
});
}
function shouldFilter(type, url, method, async) {
return (
filter &&
!filter.find((obj) => {
switch (true) {
case obj.type && obj.type !== type:
case toString.call(obj.url) === "[object String]" &&
!url.includes(obj.url):
case toString.call(obj.url) === "[object RegExp]" &&
!obj.url.test(url):
case obj.method && obj.method.toUpperCase() !== method.toUpperCase():
case "async" in obj && obj.async !== async :
return false;
}
return true;
})
);
}
function parseHeaders(obj) {
const headers = {};
switch (toString.call(obj)) {
case "[object String]":
for (const line of obj.trim().split(/[\r\n]+/)) {
const parts = line.split(/\s*:\s*/);
if (parts.length !== 2) continue;
const lheader = parts[0].toLowerCase();
if (lheader in headers) {
headers[lheader] += ", " + parts[1];
} else {
headers[lheader] = parts[1];
}
}
return headers;
case "[object Headers]":
for (const [key, val] of obj) {
headers[key] = val;
}
return headers;
case "[object Object]":
return {
...obj,
};
default:
return headers;
}
}
class AHRequest {
constructor(request) {
this.request = request;
this.requestClone = {
...this.request,
};
this.response = {};
}
waitForHookFns() {
return Promise.all(
hookFns.map((fn) => {
try {
return Promise.resolve(fn(this.request)).then(emptyFn, errorFn);
} catch (err) {
console.error(err);
}
})
);
}
waitForResponseFn() {
try {
return Promise.resolve(this.request.response(this.response)).then(
emptyFn,
errorFn
);
} catch (err) {
console.error(err);
return Promise.resolve();
}
}
waitForRequestKeys() {
if (this.reqPromise) return this.reqPromise;
const requestKeys = ["url", "method", "abort", "headers", "data"];
return (this.reqPromise = this.waitForHookFns().then(() =>
Promise.all(
requestKeys.map((key) =>
Promise.resolve(this.request[key]).then(
(val) => (this.request[key] = val),
(e) => (this.request[key] = this.requestClone[key])
)
)
)
));
}
waitForResponseKeys() {
if (this.resPromise) return this.resPromise;
const responseKeys =
this.request.type === "xhr" ? xhrResponses : fetchResponses;
return (this.resPromise = this.waitForResponseFn().then(() =>
Promise.all(
responseKeys.map((key) => {
const descriptor = getDescriptor(this.response, key);
if (descriptor && "value" in descriptor) {
return Promise.resolve(descriptor.value).then(
(val) => (this.response[key] = val),
(e) => delete this.response[key]
);
} else {
delete this.response[key];
}
})
)
));
}
}
class XhrEvents {
constructor() {
this.events = {};
}
add(type, event) {
if (type.startsWith("on")) {
this.events[type] = typeof event === "function" ? event : null;
} else {
this.events[type] = this.events[type] || new Set();
this.events[type].add(event);
}
}
remove(type, event) {
if (type.startsWith("on")) {
this.events[type] = null;
} else {
this.events[type] && this.events[type].delete(event);
}
}
_sIP() {
this.ajaxHooker_isStopped = true;
}
trigger(e) {
if (e.ajaxHooker_isTriggered || e.ajaxHooker_isStopped) return;
e.stopImmediatePropagation = this._sIP;
this.events[e.type] &&
this.events[e.type].forEach((fn) => {
!e.ajaxHooker_isStopped && fn.call(e.target, e);
});
this.events["on" + e.type] &&
this.events["on" + e.type].call(e.target, e);
e.ajaxHooker_isTriggered = true;
}
clone() {
const eventsClone = new XhrEvents();
for (const type in this.events) {
if (type.startsWith("on")) {
eventsClone.events[type] = this.events[type];
} else {
eventsClone.events[type] = new Set([...this.events[type]]);
}
}
return eventsClone;
}
}
const xhrMethods = {
readyStateChange(e) {
if (e.target.readyState === 4) {
e.target.dispatchEvent(
new CustomEvent("ajaxHooker_responseReady", {
detail: e,
})
);
} else {
e.target.__ajaxHooker.eventTrigger(e);
}
},
asyncListener(e) {
e.target.__ajaxHooker.eventTrigger(e);
},
setRequestHeader(header, value) {
const ah = this.__ajaxHooker;
ah.originalXhr.setRequestHeader(header, value);
if (this.readyState !== 1) return;
if (header in ah.headers) {
ah.headers[header] += ", " + value;
} else {
ah.headers[header] = value;
}
},
addEventListener(...args) {
const ah = this.__ajaxHooker;
if (xhrAsyncEvents.includes(args[0])) {
ah.proxyEvents.add(args[0], args[1]);
} else {
ah.originalXhr.addEventListener(...args);
}
},
removeEventListener(...args) {
const ah = this.__ajaxHooker;
if (xhrAsyncEvents.includes(args[0])) {
ah.proxyEvents.remove(args[0], args[1]);
} else {
ah.originalXhr.removeEventListener(...args);
}
},
open(method, url, async = true, ...args) {
const ah = this.__ajaxHooker;
ah.url = url.toString();
ah.method = method.toUpperCase();
ah.async = !!async;
ah.openArgs = args;
ah.headers = {};
for (const key of xhrResponses) {
ah.proxyProps[key] = {
get: () => {
const val = ah.originalXhr[key];
ah.originalXhr.dispatchEvent(
new CustomEvent("ajaxHooker_readResponse", {
detail: {
key,
val,
},
})
);
return val;
},
};
}
return ah.originalXhr.open(method, url, ...args);
},
sendFactory(realSend) {
return function (data) {
const ah = this.__ajaxHooker;
const xhr = ah.originalXhr;
if (xhr.readyState !== 1) return realSend.call(xhr, data);
ah.eventTrigger = (e) => ah.proxyEvents.trigger(e);
if (shouldFilter("xhr", ah.url, ah.method, ah.async)) {
xhr.addEventListener(
"ajaxHooker_responseReady",
(e) => {
ah.eventTrigger(e.detail);
}, {
once: true,
}
);
return realSend.call(xhr, data);
}
const request = {
type: "xhr",
url: ah.url,
method: ah.method,
abort: false,
headers: ah.headers,
data: data,
response: null,
async: ah.async,
};
if (!ah.async) {
const requestClone = {
...request,
};
hookFns.forEach((fn) => {
try {
toString.call(fn) === "[object Function]" && fn(request);
} catch (err) {
console.error(err);
}
});
for (const key in request) {
if (toString.call(request[key]) === "[object Promise]") {
request[key] = requestClone[key];
}
}
xhr.open(request.method, request.url, ah.async, ...ah.openArgs);
for (const header in request.headers) {
xhr.setRequestHeader(header, request.headers[header]);
}
data = request.data;
xhr.addEventListener(
"ajaxHooker_responseReady",
(e) => {
ah.eventTrigger(e.detail);
}, {
once: true,
}
);
realSend.call(xhr, data);
win.apiCounter--;
win.lastResponseEndTime = Date.now();
if (toString.call(request.response) === "[object Function]") {
const response = {
finalUrl: xhr.responseURL,
status: xhr.status,
responseHeaders: parseHeaders(xhr.getAllResponseHeaders()),
};
for (const key of xhrResponses) {
defineProp(
response,
key,
() => {
return (response[key] = ah.originalXhr[key]);
},
(val) => {
if (toString.call(val) !== "[object Promise]") {
delete response[key];
response[key] = val;
}
}
);
}
try {
request.response(response);
} catch (err) {
console.error(err);
}
for (const key of xhrResponses) {
ah.proxyProps[key] = {
get: () => response[key],
};
}
}
return;
}
const req = new AHRequest(request);
req.waitForRequestKeys().then(() => {
if (request.abort) return;
xhr.open(request.method, request.url, ...ah.openArgs);
for (const header in request.headers) {
xhr.setRequestHeader(header, request.headers[header]);
}
data = request.data;
xhr.addEventListener(
"ajaxHooker_responseReady",
(e) => {
if (typeof request.response !== "function")
return ah.eventTrigger(e.detail);
req.response = {
finalUrl: xhr.responseURL,
status: xhr.status,
responseHeaders: parseHeaders(xhr.getAllResponseHeaders()),
};
for (const key of xhrResponses) {
defineProp(
req.response,
key,
() => {
return (req.response[key] = ah.originalXhr[key]);
},
(val) => {
delete req.response[key];
req.response[key] = val;
}
);
}
const resPromise = req.waitForResponseKeys().then(() => {
for (const key of xhrResponses) {
if (!(key in req.response)) continue;
ah.proxyProps[key] = {
get: () => {
const val = req.response[key];
xhr.dispatchEvent(
new CustomEvent(
"ajaxHooker_readResponse", {
detail: {
key,
val,
},
})
);
return val;
},
};
}
});
xhr.addEventListener("ajaxHooker_readResponse", (e) => {
const descriptor = getDescriptor(req.response, e.detail
.key);
if (!descriptor || "get" in descriptor) {
req.response[e.detail.key] = e.detail.val;
}
});
const eventsClone = ah.proxyEvents.clone();
ah.eventTrigger = (event) =>
resPromise.then(() => eventsClone.trigger(event));
ah.eventTrigger(e.detail);
}, {
once: true,
}
);
realSend.call(xhr, data);
win.apiCounter--;
win.lastResponseEndTime = Date.now();
});
};
},
};
function fakeXhr() {
const xhr = new realXhr();
let ah = xhr.__ajaxHooker;
let xhrProxy = xhr;
if (!ah) {
const proxyEvents = new XhrEvents();
ah = xhr.__ajaxHooker = {
headers: {},
originalXhr: xhr,
proxyProps: {},
proxyEvents: proxyEvents,
eventTrigger: (e) => proxyEvents.trigger(e),
toJSON: emptyFn, // Converting circular structure to JSON
};
xhrProxy = new Proxy(xhr, {
get(target, prop) {
try {
if (target === xhr) {
if (prop in ah.proxyProps) {
const descriptor = ah.proxyProps[prop];
return descriptor.get ? descriptor.get() : descriptor.value;
}
if (typeof xhr[prop] === "function") return xhr[prop].bind(xhr);
}
} catch (err) {
console.error(err);
}
return target[prop];
},
set(target, prop, value) {
try {
if (target === xhr && prop in ah.proxyProps) {
const descriptor = ah.proxyProps[prop];
descriptor.set ?
descriptor.set(value) :
(descriptor.value = value);
} else {
target[prop] = value;
}
} catch (err) {
console.error(err);
}
return true;
},
});
xhr.addEventListener("readystatechange", xhrMethods.readyStateChange);
xhr.addEventListener("load", xhrMethods.asyncListener);
xhr.addEventListener("loadend", xhrMethods.asyncListener);
for (const evt of xhrAsyncEvents) {
const onEvt = "on" + evt;
ah.proxyProps[onEvt] = {
get: () => proxyEvents.events[onEvt] || null,
set: (val) => proxyEvents.add(onEvt, val),
};
}
for (const method of [
"setRequestHeader",
"addEventListener",
"removeEventListener",
"open",
]) {
ah.proxyProps[method] = {
value: xhrMethods[method],
};
}
}
ah.proxyProps.send = {
value: xhrMethods.sendFactory(xhr.send),
};
return xhrProxy;
}
function hookFetchResponse(response, req) {
if (response.status === 204) {
req.waitForResponseFn()
} else {
for (const key of fetchResponses) {
response[key] = () =>
new Promise((resolve, reject) => {
if (key in req.response) return resolve(req.response[key]);
resProto[key].call(response).then((res) => {
req.response[key] = res;
req.waitForResponseKeys().then(() => {
resolve(key in req.response ? req.response[key] : res);
});
}, reject);
});
}
}
}
function fakeFetch(url, options = {}) {
if (!url) return realFetch.call(win, url, options);
let init = {
...options,
};
if (toString.call(url) === "[object Request]") {
init = {};
for (const prop of fetchInitProps) init[prop] = url[prop];
Object.assign(init, options);
url = url.url;
}
url = url.toString();
init.method = init.method || "GET";
init.headers = init.headers || {};
if (shouldFilter("fetch", url, init.method, true))
return realFetch.call(win, url, init);
const request = {
type: "fetch",
url: url,
method: init.method.toUpperCase(),
abort: false,
headers: parseHeaders(init.headers),
data: init.body,
response: null,
async: true,
};
const req = new AHRequest(request);
return new Promise((resolve, reject) => {
req
.waitForRequestKeys()
.then(() => {
if (request.abort)
return reject(new DOMException("aborted", "AbortError"));
init.method = request.method;
init.headers = request.headers;
init.body = request.data;
realFetch.call(win, request.url, init).then((response) => {
win.apiCounter--;
win.lastResponseEndTime = Date.now();
if (typeof request.response === "function") {
req.response = {
finalUrl: response.url,
status: response.status,
responseHeaders: parseHeaders(response.headers),
};
hookFetchResponse(response, req);
response.clone = () => {
const resClone = resProto.clone.call(response);
hookFetchResponse(resClone, req);
return resClone;
};
}
resolve(response);
}, reject);
})
.catch((err) => {
console.error(err);
resolve(realFetch.call(win, url, init));
win.apiCounter--;
win.lastResponseEndTime = Date.now();
});
});
}
win.XMLHttpRequest = fakeXhr;
Object.keys(realXhr).forEach((key) => (fakeXhr[key] = realXhr[key]));
fakeXhr.prototype = realXhr.prototype;
win.fetch = fakeFetch;
return {
hook: (fn) => hookFns.push(fn),
filter: (arr) => {
filter = Array.isArray(arr) && arr;
},
protect: () => {
readonly(win, "XMLHttpRequest", fakeXhr);
readonly(win, "fetch", fakeFetch);
},
unhook: () => {
writable(win, "XMLHttpRequest", realXhr);
writable(win, "fetch", realFetch);
},
};
})();