Skip to content

Commit

Permalink
Merge pull request #710 from quoid/api-xhr-improvements
Browse files Browse the repository at this point in the history
feat: add document type support and performance improvements for xhr
  • Loading branch information
ACTCD authored Sep 6, 2024
2 parents 8f06f95 + 1ef027b commit 75ee89b
Show file tree
Hide file tree
Showing 4 changed files with 115 additions and 79 deletions.
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,6 @@ Userscripts currently supports the following api methods. All methods are asynch
- `status`
- `statusText`
- `timeout`
- `withCredentials`
- `responseText` (when `responseType` is `text`)
- returns an object with a single property, `abort`, which is a `Function`
- usage: `const foo = GM.xmlHttpRequest({...});` ... `foo.abort();` to abort the request
Expand Down
128 changes: 59 additions & 69 deletions src/ext/background/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,6 @@ function userscriptSort(a, b) {
return Number(a.scriptObject.weight) < Number(b.scriptObject.weight);
}

async function readAsDataURL(blob) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => resolve(reader.result); // base64data
});
}

async function getPlatform() {
let platform = localStorage.getItem("platform");
if (!platform) {
Expand Down Expand Up @@ -400,95 +392,82 @@ async function handleMessage(message, sender) {
return { status: "fulfilled", result };
}
case "API_XHR": {
// parse details and set up for XMLHttpRequest
const details = message.details;
const method = details.method ? details.method : "GET";
const user = details.user || null;
const password = details.password || null;
let body = details.data || null;
if (body != null && details.binary != null) {
const len = body.length;
const arr = new Uint8Array(len);
for (let i = 0; i < len; i++) {
arr[i] = body.charCodeAt(i);
}
body = new Blob([arr], { type: "text/plain" });
}
// initializing an xhr instance
const xhr = new XMLHttpRequest();
// establish a long-lived port connection to content script
const port = browser.tabs.connect(sender.tab.id, {
name: message.xhrPortName,
});
// set up XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.withCredentials = details.user && details.password;
xhr.timeout = details.timeout || 0;
if (details.overrideMimeType) {
xhr.overrideMimeType(details.overrideMimeType);
// receive messages from content script and process them
port.onMessage.addListener((msg) => {
if (msg.name === "ABORT") xhr.abort();
if (msg.name === "DISCONNECT") port.disconnect();
});
// handle port disconnect and clean tasks
port.onDisconnect.addListener((p) => {
if (p?.error) {
console.error(
`port disconnected due to an error: ${p.error.message}`,
);
}
});
// parse details and set up for xhr instance
const details = message.details;
const method = details.method || "GET";
const user = details.user || null;
const password = details.password || null;
let body = details.data || null;
// deprecate once body supports more data types
// the `binary` key will no longer needed
if (typeof body === "string" && details.binary) {
body = new TextEncoder().encode(body);
}
// xhr instances automatically filter out unexpected user values
xhr.timeout = details.timeout;
xhr.responseType = details.responseType;
// record parsed values for subsequent use
const responseType = xhr.responseType;
// avoid unexpected behavior of legacy defaults such as parsing XML
if (responseType === "") xhr.responseType = "text";
// transfer to content script via arraybuffer and then parse to blob
if (responseType === "blob") xhr.responseType = "arraybuffer";
// transfer to content script via text and then parse to document
if (responseType === "document") xhr.responseType = "text";
// add required listeners and send result back to the content script
for (const e of message.events) {
if (!details[e]) continue;
xhr[e] = async (event) => {
// can not send xhr through postMessage
// construct new object to be sent as "response"
const x = {
contentType: undefined, // non-standard
readyState: xhr.readyState,
response: xhr.response,
responseHeaders: xhr.getAllResponseHeaders(),
responseType: xhr.responseType,
responseType,
responseURL: xhr.responseURL,
status: xhr.status,
statusText: xhr.statusText,
timeout: xhr.timeout,
withCredentials: xhr.withCredentials,
};
// only include responseText when needed
if (["", "text"].indexOf(xhr.responseType) !== -1) {
x.responseText = xhr.responseText;
// get content-type when headers received
if (xhr.readyState >= xhr.HEADERS_RECEIVED) {
x.contentType = xhr.getResponseHeader("Content-Type");
}
// only process when xhr is complete and data exist
if (xhr.readyState === 4 && xhr.response !== null) {
if (xhr.readyState === xhr.DONE && xhr.response !== null) {
// need to convert arraybuffer data to postMessage
if (xhr.responseType === "arraybuffer") {
const arr = Array.from(new Uint8Array(xhr.response));
x.response = arr;
}
// need to convert blob data to postMessage
if (xhr.responseType === "blob") {
const base64data = await readAsDataURL(xhr.response);
x.response = {
data: base64data,
type: xhr.responseType,
};
if (
xhr.responseType === "arraybuffer" &&
xhr.response instanceof ArrayBuffer
) {
const buffer = xhr.response;
x.response = Array.from(new Uint8Array(buffer));
}
}
port.postMessage({ name: e, event, response: x });
};
}
xhr.open(method, details.url, true, user, password);
xhr.responseType = details.responseType || "";
if (details.headers) {
for (const key in details.headers) {
if (!key.startsWith("Proxy-") && !key.startsWith("Sec-")) {
const val = details.headers[key];
xhr.setRequestHeader(key, val);
}
}
}
// receive messages from content script and process them
port.onMessage.addListener((msg) => {
if (msg.name === "ABORT") xhr.abort();
if (msg.name === "DISCONNECT") port.disconnect();
});
// handle port disconnect and clean tasks
port.onDisconnect.addListener((p) => {
if (p?.error) {
console.error(
`port disconnected due to an error: ${p.error.message}`,
);
}
});
xhr.send(body);
// if onloadend not set in xhr details
// onloadend event won't be passed to content script
// if that happens port DISCONNECT message won't be posted
Expand All @@ -498,6 +477,17 @@ async function handleMessage(message, sender) {
port.postMessage({ name: "onloadend", event });
};
}
if (details.overrideMimeType) {
xhr.overrideMimeType(details.overrideMimeType);
}
xhr.open(method, details.url, true, user, password);
// must set headers after `xhr.open()`, but before `xhr.send()`
if (typeof details.headers === "object") {
for (const [key, val] of Object.entries(details.headers)) {
xhr.setRequestHeader(key, val);
}
}
xhr.send(body);
return { status: "fulfilled" };
}
case "REFRESH_DNR_RULES": {
Expand Down
64 changes: 55 additions & 9 deletions src/ext/content-scripts/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,10 @@ function xhr(details) {
const response = {
abort: () => console.error("xhr has not yet been initialized"),
};
// port listener, most of the messaging logic goes here
/**
* port listener, most of the messaging logic goes here
* @type {Parameters<typeof browser.runtime.onConnect.addListener>[0]}
*/
const listener = (port) => {
if (port.name !== xhrPortName) return;
port.onMessage.addListener(async (msg) => {
Expand All @@ -139,23 +142,67 @@ function xhr(details) {
) {
// process xhr response
const r = msg.response;
// only include responseText when needed
if (["", "text"].includes(r.responseType)) {
r.responseText = r.response;
}
/**
* only include responseXML when needed
* NOTE: Only add implementation at this time, not enable, to avoid
* unnecessary calculations, and this legacy default behavior is not
* recommended, users should explicitly use `responseType: "document"`
* to obtain it.
if (r.responseType === "") {
const mimeTypes = [
"text/xml",
"application/xml",
"application/xhtml+xml",
"image/svg+xml",
];
for (const mimeType of mimeTypes) {
if (r.contentType.includes(mimeType)) {
const parser = new DOMParser();
r.responseXML = parser.parseFromString(r.response, "text/xml");
break;
}
}
}
*/
// only process when xhr is complete and data exist
if (r.readyState === 4 && r.response !== null) {
if (r.responseType === "arraybuffer") {
if (r.responseType === "arraybuffer" && Array.isArray(r.response)) {
// arraybuffer responses had their data converted in background
// convert it back to arraybuffer
try {
const buffer = new Uint8Array(r.response).buffer;
r.response = buffer;
r.response = new Uint8Array(r.response).buffer;
} catch (err) {
console.error("error parsing xhr arraybuffer", err);
}
} else if (r.responseType === "blob" && r.response.data) {
}
if (r.responseType === "blob" && Array.isArray(r.response)) {
// blob responses had their data converted in background
// convert it back to blob
const resp = await fetch(r.response.data);
const b = await resp.blob();
r.response = b;
try {
const typedArray = new Uint8Array(r.response);
const type = r.contentType ?? "";
r.response = new Blob([typedArray], { type });
} catch (err) {
console.error("error parsing xhr blob", err);
}
}
if (r.responseType === "document" && typeof r.response === "string") {
// document responses had their data converted in background
// convert it back to document
try {
const parser = new DOMParser();
const mimeType = r.contentType.includes("text/html")
? "text/html"
: "text/xml";
r.response = parser.parseFromString(r.response, mimeType);
r.responseXML = r.response;
} catch (err) {
console.error("error parsing xhr document", err);
}
}
}
// call userscript method
Expand All @@ -167,7 +214,6 @@ function xhr(details) {
port.postMessage({ name: "DISCONNECT" });
}
});

// handle port disconnect and clean tasks
port.onDisconnect.addListener((p) => {
if (p?.error) {
Expand Down
1 change: 1 addition & 0 deletions src/ext/content-scripts/entry-userscripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ async function injection() {
scriptHandler: data.scriptHandler,
scriptHandlerVersion: data.scriptHandlerVersion,
scriptMetaStr: userscript.scriptMetaStr,
version: data.scriptHandlerVersion,
};
// add GM_info
userscript.apis.GM_info = userscript.apis.GM.info;
Expand Down

0 comments on commit 75ee89b

Please sign in to comment.