Skip to content

Commit 75ee89b

Browse files
authored
Merge pull request #710 from quoid/api-xhr-improvements
feat: add document type support and performance improvements for xhr
2 parents 8f06f95 + 1ef027b commit 75ee89b

File tree

4 files changed

+115
-79
lines changed

4 files changed

+115
-79
lines changed

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,6 @@ Userscripts currently supports the following api methods. All methods are asynch
280280
- `status`
281281
- `statusText`
282282
- `timeout`
283-
- `withCredentials`
284283
- `responseText` (when `responseType` is `text`)
285284
- returns an object with a single property, `abort`, which is a `Function`
286285
- usage: `const foo = GM.xmlHttpRequest({...});` ... `foo.abort();` to abort the request

src/ext/background/main.js

Lines changed: 59 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,6 @@ function userscriptSort(a, b) {
2121
return Number(a.scriptObject.weight) < Number(b.scriptObject.weight);
2222
}
2323

24-
async function readAsDataURL(blob) {
25-
return new Promise((resolve) => {
26-
const reader = new FileReader();
27-
reader.readAsDataURL(blob);
28-
reader.onloadend = () => resolve(reader.result); // base64data
29-
});
30-
}
31-
3224
async function getPlatform() {
3325
let platform = localStorage.getItem("platform");
3426
if (!platform) {
@@ -400,95 +392,82 @@ async function handleMessage(message, sender) {
400392
return { status: "fulfilled", result };
401393
}
402394
case "API_XHR": {
403-
// parse details and set up for XMLHttpRequest
404-
const details = message.details;
405-
const method = details.method ? details.method : "GET";
406-
const user = details.user || null;
407-
const password = details.password || null;
408-
let body = details.data || null;
409-
if (body != null && details.binary != null) {
410-
const len = body.length;
411-
const arr = new Uint8Array(len);
412-
for (let i = 0; i < len; i++) {
413-
arr[i] = body.charCodeAt(i);
414-
}
415-
body = new Blob([arr], { type: "text/plain" });
416-
}
395+
// initializing an xhr instance
396+
const xhr = new XMLHttpRequest();
417397
// establish a long-lived port connection to content script
418398
const port = browser.tabs.connect(sender.tab.id, {
419399
name: message.xhrPortName,
420400
});
421-
// set up XMLHttpRequest
422-
const xhr = new XMLHttpRequest();
423-
xhr.withCredentials = details.user && details.password;
424-
xhr.timeout = details.timeout || 0;
425-
if (details.overrideMimeType) {
426-
xhr.overrideMimeType(details.overrideMimeType);
401+
// receive messages from content script and process them
402+
port.onMessage.addListener((msg) => {
403+
if (msg.name === "ABORT") xhr.abort();
404+
if (msg.name === "DISCONNECT") port.disconnect();
405+
});
406+
// handle port disconnect and clean tasks
407+
port.onDisconnect.addListener((p) => {
408+
if (p?.error) {
409+
console.error(
410+
`port disconnected due to an error: ${p.error.message}`,
411+
);
412+
}
413+
});
414+
// parse details and set up for xhr instance
415+
const details = message.details;
416+
const method = details.method || "GET";
417+
const user = details.user || null;
418+
const password = details.password || null;
419+
let body = details.data || null;
420+
// deprecate once body supports more data types
421+
// the `binary` key will no longer needed
422+
if (typeof body === "string" && details.binary) {
423+
body = new TextEncoder().encode(body);
427424
}
425+
// xhr instances automatically filter out unexpected user values
426+
xhr.timeout = details.timeout;
427+
xhr.responseType = details.responseType;
428+
// record parsed values for subsequent use
429+
const responseType = xhr.responseType;
430+
// avoid unexpected behavior of legacy defaults such as parsing XML
431+
if (responseType === "") xhr.responseType = "text";
432+
// transfer to content script via arraybuffer and then parse to blob
433+
if (responseType === "blob") xhr.responseType = "arraybuffer";
434+
// transfer to content script via text and then parse to document
435+
if (responseType === "document") xhr.responseType = "text";
428436
// add required listeners and send result back to the content script
429437
for (const e of message.events) {
430438
if (!details[e]) continue;
431439
xhr[e] = async (event) => {
432440
// can not send xhr through postMessage
433441
// construct new object to be sent as "response"
434442
const x = {
443+
contentType: undefined, // non-standard
435444
readyState: xhr.readyState,
436445
response: xhr.response,
437446
responseHeaders: xhr.getAllResponseHeaders(),
438-
responseType: xhr.responseType,
447+
responseType,
439448
responseURL: xhr.responseURL,
440449
status: xhr.status,
441450
statusText: xhr.statusText,
442451
timeout: xhr.timeout,
443-
withCredentials: xhr.withCredentials,
444452
};
445-
// only include responseText when needed
446-
if (["", "text"].indexOf(xhr.responseType) !== -1) {
447-
x.responseText = xhr.responseText;
453+
// get content-type when headers received
454+
if (xhr.readyState >= xhr.HEADERS_RECEIVED) {
455+
x.contentType = xhr.getResponseHeader("Content-Type");
448456
}
449457
// only process when xhr is complete and data exist
450-
if (xhr.readyState === 4 && xhr.response !== null) {
458+
if (xhr.readyState === xhr.DONE && xhr.response !== null) {
451459
// need to convert arraybuffer data to postMessage
452-
if (xhr.responseType === "arraybuffer") {
453-
const arr = Array.from(new Uint8Array(xhr.response));
454-
x.response = arr;
455-
}
456-
// need to convert blob data to postMessage
457-
if (xhr.responseType === "blob") {
458-
const base64data = await readAsDataURL(xhr.response);
459-
x.response = {
460-
data: base64data,
461-
type: xhr.responseType,
462-
};
460+
if (
461+
xhr.responseType === "arraybuffer" &&
462+
xhr.response instanceof ArrayBuffer
463+
) {
464+
const buffer = xhr.response;
465+
x.response = Array.from(new Uint8Array(buffer));
463466
}
464467
}
465468
port.postMessage({ name: e, event, response: x });
466469
};
467470
}
468-
xhr.open(method, details.url, true, user, password);
469-
xhr.responseType = details.responseType || "";
470-
if (details.headers) {
471-
for (const key in details.headers) {
472-
if (!key.startsWith("Proxy-") && !key.startsWith("Sec-")) {
473-
const val = details.headers[key];
474-
xhr.setRequestHeader(key, val);
475-
}
476-
}
477-
}
478-
// receive messages from content script and process them
479-
port.onMessage.addListener((msg) => {
480-
if (msg.name === "ABORT") xhr.abort();
481-
if (msg.name === "DISCONNECT") port.disconnect();
482-
});
483-
// handle port disconnect and clean tasks
484-
port.onDisconnect.addListener((p) => {
485-
if (p?.error) {
486-
console.error(
487-
`port disconnected due to an error: ${p.error.message}`,
488-
);
489-
}
490-
});
491-
xhr.send(body);
492471
// if onloadend not set in xhr details
493472
// onloadend event won't be passed to content script
494473
// if that happens port DISCONNECT message won't be posted
@@ -498,6 +477,17 @@ async function handleMessage(message, sender) {
498477
port.postMessage({ name: "onloadend", event });
499478
};
500479
}
480+
if (details.overrideMimeType) {
481+
xhr.overrideMimeType(details.overrideMimeType);
482+
}
483+
xhr.open(method, details.url, true, user, password);
484+
// must set headers after `xhr.open()`, but before `xhr.send()`
485+
if (typeof details.headers === "object") {
486+
for (const [key, val] of Object.entries(details.headers)) {
487+
xhr.setRequestHeader(key, val);
488+
}
489+
}
490+
xhr.send(body);
501491
return { status: "fulfilled" };
502492
}
503493
case "REFRESH_DNR_RULES": {

src/ext/content-scripts/api.js

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,10 @@ function xhr(details) {
129129
const response = {
130130
abort: () => console.error("xhr has not yet been initialized"),
131131
};
132-
// port listener, most of the messaging logic goes here
132+
/**
133+
* port listener, most of the messaging logic goes here
134+
* @type {Parameters<typeof browser.runtime.onConnect.addListener>[0]}
135+
*/
133136
const listener = (port) => {
134137
if (port.name !== xhrPortName) return;
135138
port.onMessage.addListener(async (msg) => {
@@ -139,23 +142,67 @@ function xhr(details) {
139142
) {
140143
// process xhr response
141144
const r = msg.response;
145+
// only include responseText when needed
146+
if (["", "text"].includes(r.responseType)) {
147+
r.responseText = r.response;
148+
}
149+
/**
150+
* only include responseXML when needed
151+
* NOTE: Only add implementation at this time, not enable, to avoid
152+
* unnecessary calculations, and this legacy default behavior is not
153+
* recommended, users should explicitly use `responseType: "document"`
154+
* to obtain it.
155+
if (r.responseType === "") {
156+
const mimeTypes = [
157+
"text/xml",
158+
"application/xml",
159+
"application/xhtml+xml",
160+
"image/svg+xml",
161+
];
162+
for (const mimeType of mimeTypes) {
163+
if (r.contentType.includes(mimeType)) {
164+
const parser = new DOMParser();
165+
r.responseXML = parser.parseFromString(r.response, "text/xml");
166+
break;
167+
}
168+
}
169+
}
170+
*/
142171
// only process when xhr is complete and data exist
143172
if (r.readyState === 4 && r.response !== null) {
144-
if (r.responseType === "arraybuffer") {
173+
if (r.responseType === "arraybuffer" && Array.isArray(r.response)) {
145174
// arraybuffer responses had their data converted in background
146175
// convert it back to arraybuffer
147176
try {
148-
const buffer = new Uint8Array(r.response).buffer;
149-
r.response = buffer;
177+
r.response = new Uint8Array(r.response).buffer;
150178
} catch (err) {
151179
console.error("error parsing xhr arraybuffer", err);
152180
}
153-
} else if (r.responseType === "blob" && r.response.data) {
181+
}
182+
if (r.responseType === "blob" && Array.isArray(r.response)) {
154183
// blob responses had their data converted in background
155184
// convert it back to blob
156-
const resp = await fetch(r.response.data);
157-
const b = await resp.blob();
158-
r.response = b;
185+
try {
186+
const typedArray = new Uint8Array(r.response);
187+
const type = r.contentType ?? "";
188+
r.response = new Blob([typedArray], { type });
189+
} catch (err) {
190+
console.error("error parsing xhr blob", err);
191+
}
192+
}
193+
if (r.responseType === "document" && typeof r.response === "string") {
194+
// document responses had their data converted in background
195+
// convert it back to document
196+
try {
197+
const parser = new DOMParser();
198+
const mimeType = r.contentType.includes("text/html")
199+
? "text/html"
200+
: "text/xml";
201+
r.response = parser.parseFromString(r.response, mimeType);
202+
r.responseXML = r.response;
203+
} catch (err) {
204+
console.error("error parsing xhr document", err);
205+
}
159206
}
160207
}
161208
// call userscript method
@@ -167,7 +214,6 @@ function xhr(details) {
167214
port.postMessage({ name: "DISCONNECT" });
168215
}
169216
});
170-
171217
// handle port disconnect and clean tasks
172218
port.onDisconnect.addListener((p) => {
173219
if (p?.error) {

src/ext/content-scripts/entry-userscripts.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ async function injection() {
164164
scriptHandler: data.scriptHandler,
165165
scriptHandlerVersion: data.scriptHandlerVersion,
166166
scriptMetaStr: userscript.scriptMetaStr,
167+
version: data.scriptHandlerVersion,
167168
};
168169
// add GM_info
169170
userscript.apis.GM_info = userscript.apis.GM.info;

0 commit comments

Comments
 (0)