Skip to content
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

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

Merged
merged 12 commits into from
Sep 17, 2024
Merged
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,13 +187,26 @@ Userscripts Safari currently supports the following userscript metadata:
- `@noframes`
- this key takes no value
- prevents code from being injected into nested frames
- `@grant`
- Imperative controls which special [`APIs`](#api) (if any) your script uses, one on each `@grant` line, only those API methods will be provided.
- If no `@grant` values are provided, `none` will be assumed.
- If you specify `none` and something else, `none` takes precedence.

**All userscripts need at least 1 `@match` or `@include` to run!**

## API

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.

> [!NOTE]
>
> The following API description applies to the latest development branch, you may need to check the documentation for the corresponding version. Please switch to the version you want to check via `Branches` or `Tags` at the top.
>
> For example, for the v4.x.x version of the App Store:
> https://github.com/quoid/userscripts/tree/release/4.x.x

For API type definitions, please refer to: [`types.d.ts`](https://github.com/userscriptsup/testscripts/blob/bfce18746cd6bcab0616727401fa7ab6ef4086ac/userscripts/types.d.ts)
quoid marked this conversation as resolved.
Show resolved Hide resolved

- `GM.addStyle(css)`
- `css: String`
- returns a [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise), resolved if succeeds, rejected with error message if fails
Expand Down Expand Up @@ -258,8 +271,8 @@ Userscripts currently supports the following api methods. All methods are asynch
- `headers: Object` - optional
- `overrideMimeType: String` - optional
- `timeout: Int` - optional
- `binary: Bool` - optional
- `data: String` - optional
- `binary: Bool` - optional (Deprecated, use binary data objects such as `Blob`, `ArrayBuffer`, `TypedArray`, etc. instead.)
- `data: String | Blob | ArrayBuffer | TypedArray | DataView | FormData | URLSearchParams` - optional
- `responseType: String` - optional
- refer to [`XMLHttpRequests`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest)
- event handlers:
Expand All @@ -281,10 +294,17 @@ Userscripts currently supports the following api methods. All methods are asynch
- `statusText`
- `timeout`
- `responseText` (when `responseType` is `text`)
- returns a custom [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) contains an additional property `abort`, resolved with the response object.
- usage:
- `const xhr = GM.xmlHttpRequest({...});`
- `xhr.abort();` to abort the request
- `const response = await xhr;`
- or just:
- `const response = await GM.xmlHttpRequest({...});`
- `GM_xmlhttpRequest(details)`
- Basically the same as `GM.xmlHttpRequest(details)`, except:
- returns an object with a single property, `abort`, which is a `Function`
- usage: `const foo = GM.xmlHttpRequest({...});` ... `foo.abort();` to abort the request
- `GM_xmlhttpRequest(details)`
- an alias for `GM.xmlHttpRequest`, works exactly the same

## Scripts Directory

Expand Down
2 changes: 1 addition & 1 deletion jsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@
"skipLibCheck": true,
"sourceMap": true
},
"include": ["*.js", "src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
"include": ["*.d.ts", "*.js"]
}
26 changes: 26 additions & 0 deletions src/dev/jsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// https://code.visualstudio.com/docs/languages/jsconfig
// https://www.typescriptlang.org/docs/handbook/tsconfig-json.html
// https://www.typescriptlang.org/tsconfig

// https://www.typescriptlang.org/docs/handbook/type-checking-javascript-files.html
// https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html

{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",

"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,

"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"sourceMap": true
},
"include": ["**/*.d.ts", "**/*.js", "**/*.svelte"]
}
255 changes: 166 additions & 89 deletions src/ext/background/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -392,102 +392,179 @@ async function handleMessage(message, sender) {
return { status: "fulfilled", result };
}
case "API_XHR": {
// 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,
});
// 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,
responseURL: xhr.responseURL,
status: xhr.status,
statusText: xhr.statusText,
timeout: xhr.timeout,
};
// get content-type when headers received
if (xhr.readyState >= xhr.HEADERS_RECEIVED) {
x.contentType = xhr.getResponseHeader("Content-Type");
try {
// 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,
});
// 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}`,
);
}
// only process when xhr is complete and data exist
if (xhr.readyState === xhr.DONE && xhr.response !== null) {
// need to convert arraybuffer data to postMessage
});
// 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";
/** @type {Parameters<XMLHttpRequest["open"]>[1]} */
const url = details.url;
/** @type {Parameters<XMLHttpRequest["open"]>[3]} */
const user = details.user || null;
/** @type {Parameters<XMLHttpRequest["open"]>[4]} */
const password = details.password || null;
/** @type {Parameters<XMLHttpRequest["send"]>[0]} */
let body = null;
if (typeof details.data === "object") {
/** @type {TypeExtMessages.XHRProcessedData} */
const data = details.data;
if (typeof data.data === "string") {
if (data.type === "Text") {
// deprecate once body supports more data types
// the `binary` key will no longer needed
if (details.binary) {
const binaryString = data.data;
const view = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
view[i] = binaryString.charCodeAt(i);
}
body = view;
} else {
body = data.data;
}
}
if (data.type === "Document") {
body = data.data;
if (!("content-type" in details.headers)) {
details.headers["content-type"] = data.mime;
}
}
if (data.type === "URLSearchParams") {
body = new URLSearchParams(data.data);
}
}
if (Array.isArray(data.data)) {
if (
xhr.responseType === "arraybuffer" &&
xhr.response instanceof ArrayBuffer
data.type === "ArrayBuffer" ||
data.type === "ArrayBufferView"
) {
const buffer = xhr.response;
x.response = Array.from(new Uint8Array(buffer));
body = new Uint8Array(data.data);
}
if (data.type === "Blob") {
body = new Uint8Array(data.data);
if (!("content-type" in details.headers)) {
details.headers["content-type"] = data.mime;
}
}
if (data.type === "FormData") {
body = new FormData();
for (const [k, v] of data.data) {
if (typeof v === "string") {
body.append(k, v);
} else {
const view = new Uint8Array(v.data);
body.append(
k,
new File([view], v.name, {
type: v.mime,
lastModified: v.lastModified,
}),
);
}
}
}
}
port.postMessage({ name: e, event, response: x });
};
}
// 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
// if details lacks onloadend attach listener
if (!details.onloadend) {
xhr.onloadend = (event) => {
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 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
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,
response: xhr.response,
responseHeaders: xhr.getAllResponseHeaders(),
responseType,
responseURL: xhr.responseURL,
status: xhr.status,
statusText: xhr.statusText,
timeout: xhr.timeout,
};
// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/response#value
if (xhr.readyState < xhr.DONE && xhr.responseType !== "text") {
response.response = null;
}
// get content-type when headers received
if (xhr.readyState >= xhr.HEADERS_RECEIVED) {
response.contentType = xhr.getResponseHeader("Content-Type");
}
// only process when xhr is complete and data exist
// note the status of the last `progress` event in Safari is DONE/4
// exclude this event to avoid unnecessary processing and transmission
if (
xhr.readyState === xhr.DONE &&
xhr.response !== null &&
handler !== "onprogress"
) {
// need to convert arraybuffer data to postMessage
if (
xhr.responseType === "arraybuffer" &&
xhr.response instanceof ArrayBuffer
) {
const buffer = xhr.response;
response.response = Array.from(new Uint8Array(buffer));
}
}
port.postMessage({ handler, response });
};
}
// 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
// if details lacks onloadend attach listener
if (!handlers.onloadend) {
xhr.onloadend = () => {
port.postMessage({ handler: "onloadend" });
};
}
if (details.overrideMimeType) {
xhr.overrideMimeType(details.overrideMimeType);
}
xhr.open(method, 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);
} catch (error) {
console.error(error);
}
xhr.send(body);
return { status: "fulfilled" };
}
case "REFRESH_DNR_RULES": {
Expand Down
Loading
Loading