diff --git a/README.md b/README.md index f0b3fc85..8591027c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/ext/background/main.js b/src/ext/background/main.js index 8f716472..f584ed5a 100644 --- a/src/ext/background/main.js +++ b/src/ext/background/main.js @@ -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) { @@ -400,31 +392,47 @@ 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; @@ -432,63 +440,34 @@ async function handleMessage(message, sender) { // 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 @@ -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": { diff --git a/src/ext/content-scripts/api.js b/src/ext/content-scripts/api.js index 6865b7d9..0988c063 100644 --- a/src/ext/content-scripts/api.js +++ b/src/ext/content-scripts/api.js @@ -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[0]} + */ const listener = (port) => { if (port.name !== xhrPortName) return; port.onMessage.addListener(async (msg) => { @@ -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 @@ -167,7 +214,6 @@ function xhr(details) { port.postMessage({ name: "DISCONNECT" }); } }); - // handle port disconnect and clean tasks port.onDisconnect.addListener((p) => { if (p?.error) { diff --git a/src/ext/content-scripts/entry-userscripts.js b/src/ext/content-scripts/entry-userscripts.js index eab82138..df16d1d6 100644 --- a/src/ext/content-scripts/entry-userscripts.js +++ b/src/ext/content-scripts/entry-userscripts.js @@ -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;