Skip to content

Commit 4937f21

Browse files
authored
Merge pull request #716 from quoid/api-xhr-improvements
feat: `GM.xmlHttpRequest` returns promise and supports multiple data types
2 parents 75ee89b + d1306ca commit 4937f21

File tree

6 files changed

+592
-178
lines changed

6 files changed

+592
-178
lines changed

README.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,13 +187,26 @@ Userscripts Safari currently supports the following userscript metadata:
187187
- `@noframes`
188188
- this key takes no value
189189
- prevents code from being injected into nested frames
190+
- `@grant`
191+
- Imperative controls which special [`APIs`](#api) (if any) your script uses, one on each `@grant` line, only those API methods will be provided.
192+
- If no `@grant` values are provided, `none` will be assumed.
193+
- If you specify `none` and something else, `none` takes precedence.
190194

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

193197
## API
194198

195199
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.
196200

201+
> [!NOTE]
202+
>
203+
> 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.
204+
>
205+
> For example, for the v4.x.x version of the App Store:
206+
> https://github.com/quoid/userscripts/tree/release/4.x.x
207+
208+
For API type definitions, please refer to: [`types.d.ts`](https://github.com/userscriptsup/testscripts/blob/bfce18746cd6bcab0616727401fa7ab6ef4086ac/userscripts/types.d.ts)
209+
197210
- `GM.addStyle(css)`
198211
- `css: String`
199212
- 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
@@ -258,8 +271,8 @@ Userscripts currently supports the following api methods. All methods are asynch
258271
- `headers: Object` - optional
259272
- `overrideMimeType: String` - optional
260273
- `timeout: Int` - optional
261-
- `binary: Bool` - optional
262-
- `data: String` - optional
274+
- `binary: Bool` - optional (Deprecated, use binary data objects such as `Blob`, `ArrayBuffer`, `TypedArray`, etc. instead.)
275+
- `data: String | Blob | ArrayBuffer | TypedArray | DataView | FormData | URLSearchParams` - optional
263276
- `responseType: String` - optional
264277
- refer to [`XMLHttpRequests`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest)
265278
- event handlers:
@@ -281,10 +294,17 @@ Userscripts currently supports the following api methods. All methods are asynch
281294
- `statusText`
282295
- `timeout`
283296
- `responseText` (when `responseType` is `text`)
297+
- 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.
298+
- usage:
299+
- `const xhr = GM.xmlHttpRequest({...});`
300+
- `xhr.abort();` to abort the request
301+
- `const response = await xhr;`
302+
- or just:
303+
- `const response = await GM.xmlHttpRequest({...});`
304+
- `GM_xmlhttpRequest(details)`
305+
- Basically the same as `GM.xmlHttpRequest(details)`, except:
284306
- returns an object with a single property, `abort`, which is a `Function`
285307
- usage: `const foo = GM.xmlHttpRequest({...});` ... `foo.abort();` to abort the request
286-
- `GM_xmlhttpRequest(details)`
287-
- an alias for `GM.xmlHttpRequest`, works exactly the same
288308

289309
## Scripts Directory
290310

jsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,5 @@
2222
"skipLibCheck": true,
2323
"sourceMap": true
2424
},
25-
"include": ["*.js", "src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
25+
"include": ["*.d.ts", "*.js"]
2626
}

src/dev/jsconfig.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// https://code.visualstudio.com/docs/languages/jsconfig
2+
// https://www.typescriptlang.org/docs/handbook/tsconfig-json.html
3+
// https://www.typescriptlang.org/tsconfig
4+
5+
// https://www.typescriptlang.org/docs/handbook/type-checking-javascript-files.html
6+
// https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html
7+
8+
{
9+
"compilerOptions": {
10+
"target": "ESNext",
11+
"module": "ESNext",
12+
"moduleResolution": "bundler",
13+
14+
"verbatimModuleSyntax": true,
15+
"isolatedModules": true,
16+
"resolveJsonModule": true,
17+
18+
"allowJs": true,
19+
"checkJs": true,
20+
"esModuleInterop": true,
21+
"forceConsistentCasingInFileNames": true,
22+
"skipLibCheck": true,
23+
"sourceMap": true
24+
},
25+
"include": ["**/*.d.ts", "**/*.js", "**/*.svelte"]
26+
}

src/ext/background/main.js

Lines changed: 166 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -392,102 +392,179 @@ async function handleMessage(message, sender) {
392392
return { status: "fulfilled", result };
393393
}
394394
case "API_XHR": {
395-
// initializing an xhr instance
396-
const xhr = new XMLHttpRequest();
397-
// establish a long-lived port connection to content script
398-
const port = browser.tabs.connect(sender.tab.id, {
399-
name: message.xhrPortName,
400-
});
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);
424-
}
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";
436-
// add required listeners and send result back to the content script
437-
for (const e of message.events) {
438-
if (!details[e]) continue;
439-
xhr[e] = async (event) => {
440-
// can not send xhr through postMessage
441-
// construct new object to be sent as "response"
442-
const x = {
443-
contentType: undefined, // non-standard
444-
readyState: xhr.readyState,
445-
response: xhr.response,
446-
responseHeaders: xhr.getAllResponseHeaders(),
447-
responseType,
448-
responseURL: xhr.responseURL,
449-
status: xhr.status,
450-
statusText: xhr.statusText,
451-
timeout: xhr.timeout,
452-
};
453-
// get content-type when headers received
454-
if (xhr.readyState >= xhr.HEADERS_RECEIVED) {
455-
x.contentType = xhr.getResponseHeader("Content-Type");
395+
try {
396+
// initializing an xhr instance
397+
const xhr = new XMLHttpRequest();
398+
// establish a long-lived port connection to content script
399+
const port = browser.tabs.connect(sender.tab.id, {
400+
name: message.xhrPortName,
401+
});
402+
// receive messages from content script and process them
403+
port.onMessage.addListener((msg) => {
404+
if (msg.name === "ABORT") xhr.abort();
405+
if (msg.name === "DISCONNECT") port.disconnect();
406+
});
407+
// handle port disconnect and clean tasks
408+
port.onDisconnect.addListener((p) => {
409+
if (p?.error) {
410+
console.error(
411+
`port disconnected due to an error: ${p.error.message}`,
412+
);
456413
}
457-
// only process when xhr is complete and data exist
458-
if (xhr.readyState === xhr.DONE && xhr.response !== null) {
459-
// need to convert arraybuffer data to postMessage
414+
});
415+
// parse details and set up for xhr instance
416+
/** @type {TypeExtMessages.XHRTransportableDetails} */
417+
const details = message.details;
418+
/** @type {Parameters<XMLHttpRequest["open"]>[0]} */
419+
const method = details.method || "GET";
420+
/** @type {Parameters<XMLHttpRequest["open"]>[1]} */
421+
const url = details.url;
422+
/** @type {Parameters<XMLHttpRequest["open"]>[3]} */
423+
const user = details.user || null;
424+
/** @type {Parameters<XMLHttpRequest["open"]>[4]} */
425+
const password = details.password || null;
426+
/** @type {Parameters<XMLHttpRequest["send"]>[0]} */
427+
let body = null;
428+
if (typeof details.data === "object") {
429+
/** @type {TypeExtMessages.XHRProcessedData} */
430+
const data = details.data;
431+
if (typeof data.data === "string") {
432+
if (data.type === "Text") {
433+
// deprecate once body supports more data types
434+
// the `binary` key will no longer needed
435+
if (details.binary) {
436+
const binaryString = data.data;
437+
const view = new Uint8Array(binaryString.length);
438+
for (let i = 0; i < binaryString.length; i++) {
439+
view[i] = binaryString.charCodeAt(i);
440+
}
441+
body = view;
442+
} else {
443+
body = data.data;
444+
}
445+
}
446+
if (data.type === "Document") {
447+
body = data.data;
448+
if (!("content-type" in details.headers)) {
449+
details.headers["content-type"] = data.mime;
450+
}
451+
}
452+
if (data.type === "URLSearchParams") {
453+
body = new URLSearchParams(data.data);
454+
}
455+
}
456+
if (Array.isArray(data.data)) {
460457
if (
461-
xhr.responseType === "arraybuffer" &&
462-
xhr.response instanceof ArrayBuffer
458+
data.type === "ArrayBuffer" ||
459+
data.type === "ArrayBufferView"
463460
) {
464-
const buffer = xhr.response;
465-
x.response = Array.from(new Uint8Array(buffer));
461+
body = new Uint8Array(data.data);
462+
}
463+
if (data.type === "Blob") {
464+
body = new Uint8Array(data.data);
465+
if (!("content-type" in details.headers)) {
466+
details.headers["content-type"] = data.mime;
467+
}
468+
}
469+
if (data.type === "FormData") {
470+
body = new FormData();
471+
for (const [k, v] of data.data) {
472+
if (typeof v === "string") {
473+
body.append(k, v);
474+
} else {
475+
const view = new Uint8Array(v.data);
476+
body.append(
477+
k,
478+
new File([view], v.name, {
479+
type: v.mime,
480+
lastModified: v.lastModified,
481+
}),
482+
);
483+
}
484+
}
466485
}
467486
}
468-
port.postMessage({ name: e, event, response: x });
469-
};
470-
}
471-
// if onloadend not set in xhr details
472-
// onloadend event won't be passed to content script
473-
// if that happens port DISCONNECT message won't be posted
474-
// if details lacks onloadend attach listener
475-
if (!details.onloadend) {
476-
xhr.onloadend = (event) => {
477-
port.postMessage({ name: "onloadend", event });
478-
};
479-
}
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);
488487
}
488+
// xhr instances automatically filter out unexpected user values
489+
xhr.timeout = details.timeout;
490+
xhr.responseType = details.responseType;
491+
// record parsed values for subsequent use
492+
const responseType = xhr.responseType;
493+
// avoid unexpected behavior of legacy defaults such as parsing XML
494+
if (responseType === "") xhr.responseType = "text";
495+
// transfer to content script via arraybuffer and then parse to blob
496+
if (responseType === "blob") xhr.responseType = "arraybuffer";
497+
// transfer to content script via text and then parse to document
498+
if (responseType === "document") xhr.responseType = "text";
499+
// add required listeners and send result back to the content script
500+
const handlers = details.hasHandlers || {};
501+
for (const handler of Object.keys(handlers)) {
502+
xhr[handler] = async () => {
503+
// can not send xhr through postMessage
504+
// construct new object to be sent as "response"
505+
/** @type {TypeExtMessages.XHRTransportableResponse} */
506+
const response = {
507+
contentType: undefined, // non-standard
508+
readyState: xhr.readyState,
509+
response: xhr.response,
510+
responseHeaders: xhr.getAllResponseHeaders(),
511+
responseType,
512+
responseURL: xhr.responseURL,
513+
status: xhr.status,
514+
statusText: xhr.statusText,
515+
timeout: xhr.timeout,
516+
};
517+
// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/response#value
518+
if (xhr.readyState < xhr.DONE && xhr.responseType !== "text") {
519+
response.response = null;
520+
}
521+
// get content-type when headers received
522+
if (xhr.readyState >= xhr.HEADERS_RECEIVED) {
523+
response.contentType = xhr.getResponseHeader("Content-Type");
524+
}
525+
// only process when xhr is complete and data exist
526+
// note the status of the last `progress` event in Safari is DONE/4
527+
// exclude this event to avoid unnecessary processing and transmission
528+
if (
529+
xhr.readyState === xhr.DONE &&
530+
xhr.response !== null &&
531+
handler !== "onprogress"
532+
) {
533+
// need to convert arraybuffer data to postMessage
534+
if (
535+
xhr.responseType === "arraybuffer" &&
536+
xhr.response instanceof ArrayBuffer
537+
) {
538+
const buffer = xhr.response;
539+
response.response = Array.from(new Uint8Array(buffer));
540+
}
541+
}
542+
port.postMessage({ handler, response });
543+
};
544+
}
545+
// if onloadend not set in xhr details
546+
// onloadend event won't be passed to content script
547+
// if that happens port DISCONNECT message won't be posted
548+
// if details lacks onloadend attach listener
549+
if (!handlers.onloadend) {
550+
xhr.onloadend = () => {
551+
port.postMessage({ handler: "onloadend" });
552+
};
553+
}
554+
if (details.overrideMimeType) {
555+
xhr.overrideMimeType(details.overrideMimeType);
556+
}
557+
xhr.open(method, url, true, user, password);
558+
// must set headers after `xhr.open()`, but before `xhr.send()`
559+
if (typeof details.headers === "object") {
560+
for (const [key, val] of Object.entries(details.headers)) {
561+
xhr.setRequestHeader(key, val);
562+
}
563+
}
564+
xhr.send(body);
565+
} catch (error) {
566+
console.error(error);
489567
}
490-
xhr.send(body);
491568
return { status: "fulfilled" };
492569
}
493570
case "REFRESH_DNR_RULES": {

0 commit comments

Comments
 (0)