Skip to content

feat: GM.xmlHttpRequest returns promise and supports multiple data types #716

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ Userscripts Safari currently supports the following userscript metadata:

Userscripts currently supports the following api methods. All methods are asynchronous unless otherwise noted. Users must `@grant` these methods in order to use them in a userscript. When using API methods, it's only possible to inject into the content script scope due to security concerns.

For API type definitions, please refer to: [`types.d.ts`](https://github.com/userscriptsup/testscripts/blob/363b322208a6733c7e5f4b9d9705957889af6837/userscripts/types.d.ts)
For API type definitions, please refer to: [`types.d.ts`](https://github.com/userscriptsup/testscripts/blob/bfce18746cd6bcab0616727401fa7ab6ef4086ac/userscripts/types.d.ts)

- `GM.addStyle(css)`
- `css: String`
Expand Down
4 changes: 3 additions & 1 deletion src/ext/background/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ async function handleMessage(message, sender) {
}
});
// parse details and set up for xhr instance
/** @type {TypeExtMessages.XHRTransportableDetails} */
const details = message.details;
/** @type {Parameters<XMLHttpRequest["open"]>[0]} */
const method = details.method || "GET";
Expand Down Expand Up @@ -496,11 +497,12 @@ async function handleMessage(message, sender) {
// 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
const handlers = details.handlers || {};
const handlers = details.hasHandlers || {};
for (const handler of Object.keys(handlers)) {
xhr[handler] = async () => {
// can not send xhr through postMessage
// construct new object to be sent as "response"
/** @type {TypeExtMessages.XHRTransportableResponse} */
const response = {
contentType: undefined, // non-standard
readyState: xhr.readyState,
Expand Down
90 changes: 55 additions & 35 deletions src/ext/content-scripts/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,13 @@ async function setClipboard(clipboardData, type) {
});
}

function xhrResponseProcessor(response) {
const res = response;
/**
* Restore `response.response` to required `responseType`
* @param {TypeExtMessages.XHRTransportableResponse} msgResponse
* @param {TypeExtMessages.XHRResponse} response
*/
function xhrResponseProcessor(msgResponse, response) {
const res = msgResponse;
/**
* only include responseXML when needed
* NOTE: Only add implementation at this time, not enable, to avoid
Expand All @@ -136,7 +141,7 @@ function xhrResponseProcessor(response) {
// arraybuffer responses had their data converted in background
// convert it back to arraybuffer
try {
res.response = new Uint8Array(res.response).buffer;
response.response = new Uint8Array(res.response).buffer;
} catch (err) {
console.error("error parsing xhr arraybuffer", err);
}
Expand All @@ -147,7 +152,7 @@ function xhrResponseProcessor(response) {
try {
const typedArray = new Uint8Array(res.response);
const type = res.contentType ?? "";
res.response = new Blob([typedArray], { type });
response.response = new Blob([typedArray], { type });
} catch (err) {
console.error("error parsing xhr blob", err);
}
Expand All @@ -160,8 +165,8 @@ function xhrResponseProcessor(response) {
const mimeType = res.contentType.includes("text/html")
? "text/html"
: "text/xml";
res.response = parser.parseFromString(res.response, mimeType);
res.responseXML = res.response;
response.response = parser.parseFromString(res.response, mimeType);
response.responseXML = response.response;
} catch (err) {
console.error("error parsing xhr document", err);
}
Expand Down Expand Up @@ -278,18 +283,19 @@ async function xhr(details, control, promise) {
}
// can not send details (func, blob, etc.) through message
// construct a new processed object send to background page
/** @type {TypeExtMessages.XHRTransportableDetails} */
const detailsParsed = {
binary: details.binary,
binary: Boolean(details.binary),
data: undefined,
headers: {},
method: details.method,
overrideMimeType: details.overrideMimeType,
password: details.password,
method: String(details.method),
overrideMimeType: String(details.overrideMimeType),
password: String(details.password),
responseType: details.responseType,
timeout: details.timeout,
url: details.url,
user: details.user,
handlers: {},
timeout: Number(details.timeout),
url: String(details.url),
user: String(details.user),
hasHandlers: {},
};
// preprocess data key
try {
Expand All @@ -304,6 +310,14 @@ async function xhr(details, control, promise) {
}
}
// preprocess handlers
/**
* Record the handlers existing in details to a new object
* to avoid modifying the original object, and to prevent
* the original object from being changed by user scripts
* @type {TypeExtMessages.XHRHandlersObj}
*/
const handlers = {};
/** @type {TypeExtMessages.XHRHandlers} */
const XHRHandlers = [
"onreadystatechange",
"onloadstart",
Expand All @@ -321,26 +335,28 @@ async function xhr(details, control, promise) {
typeof details[handler] === "function"
) {
// add a bool to indicate if event listeners should be attached
detailsParsed.handlers[handler] = true;
detailsParsed.hasHandlers[handler] = true;
// record to the new object
handlers[handler] = details[handler];
}
}
// resolving asynchronous xmlHttpRequest
if (promise) {
detailsParsed.handlers.onloadend = true;
const _onloadend = details.onloadend;
details.onloadend = (response) => {
detailsParsed.hasHandlers.onloadend = true;
const _onloadend = handlers.onloadend;
handlers.onloadend = (response) => {
promise.resolve(response);
if (typeof _onloadend === "function") _onloadend(response);
_onloadend?.(response);
};
}
// make sure to listen to XHR.DONE events only once, to avoid processing
// and transmitting the same response data multiple times
if (detailsParsed.handlers.onreadystatechange) {
delete detailsParsed.handlers.onload;
delete detailsParsed.handlers.onloadend;
if (detailsParsed.hasHandlers.onreadystatechange) {
delete detailsParsed.hasHandlers.onload;
delete detailsParsed.hasHandlers.onloadend;
}
if (detailsParsed.handlers.onload) {
delete detailsParsed.handlers.onloadend;
if (detailsParsed.hasHandlers.onload) {
delete detailsParsed.hasHandlers.onloadend;
}
// generate random port name for single xhr
const xhrPortName = Math.random().toString(36).substring(1, 9);
Expand All @@ -351,36 +367,40 @@ async function xhr(details, control, promise) {
const listener = (port) => {
if (port.name !== xhrPortName) return;
port.onMessage.addListener(async (msg) => {
/** @type {TypeExtMessages.XHRHandlers[number]} */
const handler = msg.handler;
if (
msg.response &&
detailsParsed.handlers[handler] &&
typeof details[handler] === "function"
detailsParsed.hasHandlers[handler] &&
typeof handlers[handler] === "function"
) {
// process xhr response
const response = msg.response;
/** @type {TypeExtMessages.XHRTransportableResponse} */
const msgResponse = msg.response;
/** @type {TypeExtMessages.XHRResponse} */
const response = msgResponse;
// only include responseText when needed
if (["", "text"].includes(response.responseType)) {
response.responseText = response.response;
}
// only process when xhr is complete and data exist
if (response.readyState === 4 && response.response !== null) {
xhrResponseProcessor(response);
xhrResponseProcessor(msgResponse, response);
}
// call userscript method
details[handler](response);
handlers[handler](response);
// call the deleted XHR.DONE handlers above
if (response.readyState === 4) {
if (handler === "onreadystatechange") {
if (typeof details.onload === "function") {
details.onload(response);
if (typeof handlers.onload === "function") {
handlers.onload(response);
}
if (typeof details.onloadend === "function") {
details.onloadend(response);
if (typeof handlers.onloadend === "function") {
handlers.onloadend(response);
}
} else if (handler === "onload") {
if (typeof details.onloadend === "function") {
details.onloadend(response);
if (typeof handlers.onloadend === "function") {
handlers.onloadend(response);
}
}
}
Expand Down
47 changes: 47 additions & 0 deletions src/ext/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,51 @@ declare namespace TypeExtMessages {
data: XHRProcessedFormData;
type: "FormData";
};

type XHRHandlers = [
"onreadystatechange",
"onloadstart",
"onprogress",
"onabort",
"onerror",
"onload",
"ontimeout",
"onloadend",
];

type XHRHandlersObj = {
[handler in XHRHandlers[number]]?: (response: XHRResponse) => void;
};

interface XHRTransportableDetails {
binary: boolean;
data: XHRProcessedData;
headers: { [x: Lowercase<string>]: string };
method: string;
overrideMimeType: string;
password: string;
responseType: XMLHttpRequestResponseType;
timeout: number;
url: string;
user: string;
hasHandlers: { [handler in XHRHandlers[number]]?: boolean };
}

interface XHRTransportableResponse {
contentType: string; // non-standard
readyState: number;
response: string | number[];
responseHeaders: string;
responseType: XMLHttpRequestResponseType;
responseURL: string;
status: number;
statusText: string;
timeout: number;
}

interface XHRResponse extends Omit<XHRTransportableResponse, "response"> {
response: any;
responseText?: string;
responseXML?: Document;
}
}
Loading