Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
81 changes: 78 additions & 3 deletions example/tests/gm_xhr_test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// ==UserScript==
// @name GM_xmlhttpRequest Exhaustive Test Harness v3
// @namespace tm-gmxhr-test
// @version 1.2.4
// @version 1.2.5
// @description Comprehensive in-page tests for GM_xmlhttpRequest: normal, abnormal, and edge cases with clear pass/fail output.
// @author you
// @match *://*/*?GM_XHR_TEST_SC
Expand Down Expand Up @@ -120,8 +120,7 @@ const enableTool = true;
gap: "8px",
},
},
h("div", { style: { fontWeight: "600" } }, "GM_xmlhttpRequest Test Harness", h("br"), `${GM.info?.version}`),
h("div", { id: "counts", style: { marginLeft: "auto", opacity: 0.8 } }, "…"),
h("div", {}, h("div", { style: { fontWeight: "500" } }, `GM_xmlhttpRequest Test Harness ${GM.info?.script?.version}`), h("div", { style: { display: "flex", flexDirection: "row" } }, h("div", { style: { fontWeight: "400" } }, `${GM.info?.scriptHandler} ${GM.info?.version}`), h("div", { id: "counts", style: { marginLeft: "auto", opacity: 0.8 } }, "…"))),
h("button", { id: "start", style: btn() }, "Run"),
h("button", { id: "clear", style: btn() }, "Clear")
),
Expand Down Expand Up @@ -938,6 +937,82 @@ const enableTool = true;
}
},
},
{
name: "GM_xhr abort timeout onloadend events",
async run(fetch) {
const runCase = (details, { abortAfterMs } = {}) => {
return new Promise((resolve, reject) => {
const events = [];
const timeoutMs = Math.max((details.timeout || 0) + (abortAfterMs || 0) + 8000, 12000);
const timer = setTimeout(() => {
reject(new Error(`Expected onloadend; events=${events.join(",")}`));
}, timeoutMs);
const req = GM_xmlhttpRequest({
method: details.method || "GET",
url: details.url,
timeout: details.timeout,
fetch,
onload() {
events.push("onload");
},
onerror() {
events.push("onerror");
},
onabort() {
events.push("onabort");
},
ontimeout() {
events.push("ontimeout");
},
onloadend(response) {
events.push("onloadend");
clearTimeout(timer);
resolve({ events, response });
},
});
if (abortAfterMs != null) {
setTimeout(() => req.abort(), abortAfterMs);
}
});
};

const normal = await runCase({
url: `${HB}/get`,
});
assertDeepEq(normal.events, ["onload", "onloadend"], "normal fires onload then onloadend");
assertEq(normal.response.status, 200, "normal onloadend status 200");

const timeout = await runCase({
url: `${HB}/delay/5`,
timeout: 2000,
});
assertDeepEq(timeout.events, ["ontimeout", "onloadend"], "timeout fires ontimeout then onloadend");

const abort = await runCase(
{
url: `${HB}/delay/10`,
},
{ abortAfterMs: 4000 }
);
assertDeepEq(abort.events, ["onabort", "onloadend"], "abort fires onabort then onloadend");

const nwError1 = await runCase(
{
url: `https://nonexistent-domain-abcxyz.test/abc.html`, // allowed domain
},
{ abortAfterMs: 500 }
);
assertDeepEq(nwError1.events, ["onerror", "onloadend"], "abort fires onerror then onloadend");

const nwError2 = await runCase(
{
url: `https://nonexistent-domain-abcxyz.reject/abc.html`, // disallowed domain
},
{ abortAfterMs: 500 }
);
assertDeepEq(nwError2.events, ["onerror", "onloadend"], "abort fires onerror then onloadend");
},
},
{
name: "onprogress fires while downloading [arraybuffer]",
async run(fetch) {
Expand Down
94 changes: 58 additions & 36 deletions src/app/service/content/gm_api/gm_xhr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ export type GMXHRResponseType = {
error?: string;
};

type TXhrCallBackArg = {
//
finalUrl: string;
readyState: ReadyStateCode;
status: number;
statusText: string;
responseHeaders: string;
error?: string;
//
useFetch: boolean;
eventType: string;
ok: boolean;
contentType: string;
};

export type GMXHRResponseTypeWithError = GMXHRResponseType & Required<Pick<GMXHRResponseType, "error">>;

export const toBlobURL = (a: GMApi, blob: Blob): Promise<string> | string => {
Expand Down Expand Up @@ -209,7 +224,7 @@ export function GM_xmlhttpRequest(
}
let connect: MessageConnect | null;
const responseTypeOriginal = details.responseType?.toLocaleLowerCase() || "";
let doAbort: any = null;
let doAbort: ((o: TXhrCallBackArg) => void) | null = null;
(async () => {
const [urlResolved, dataResolved] = await Promise.all([urlPromiseLike, dataPromise]);
const u = new URL(urlResolved, window.location.href);
Expand Down Expand Up @@ -285,6 +300,7 @@ export function GM_xmlhttpRequest(
}

let refCleanup: (() => void) | null = () => {
// 执行此操作会使连结断开。因此 fetch error, timeout 等出现后不能立即执行,应留待 onloadend 后呼叫
// 清掉函数参考,避免各变数参考无法GC
makeXHRCallbackParam = null;
onMessageHandler = null;
Expand Down Expand Up @@ -419,22 +435,7 @@ export function GM_xmlhttpRequest(
return retParamObject;
};

const makeXHRCallbackParam_ = (
res: {
//
finalUrl: string;
readyState: ReadyStateCode;
status: number;
statusText: string;
responseHeaders: string;
error?: string;
//
useFetch: boolean;
eventType: string;
ok: boolean;
contentType: string;
} & Record<string, any>
) => {
const makeXHRCallbackParam_ = (res: TXhrCallBackArg) => {
if ((res.readyState === 4 || reqDone) && res.eventType !== "progress") allowResponse = true;
let resError: Record<string, any> | null = null;
if (
Expand Down Expand Up @@ -502,12 +503,33 @@ export function GM_xmlhttpRequest(
return makeResponseRet(retParam, addGetters, res.contentType);
};
let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_;
doAbort = (data: any) => {
let loadendCalled = false;
const doLoadEnd = (data: TXhrCallBackArg) => {
if (!loadendCalled) {
loadendCalled = true;
reqDone = true;
responseText = false;
finalResultBuffers = null;
finalResultText = null;
const xhrResponse = makeXHRCallbackParam?.(data) ?? {};
details.onloadend?.(xhrResponse);
if (errorOccur === null) {
retPromiseResolve?.(xhrResponse);
} else {
retPromiseReject?.(errorOccur);
}
refCleanup?.();
}
};
doAbort = (data: TXhrCallBackArg) => {
if (!reqDone) {
errorOccur = "AbortError";
details.onabort?.(makeXHRCallbackParam?.(data) ?? {});
reqDone = true;
refCleanup?.();
// 不要进行 refCleanup !要等待最后的 onloadend
// refCleanup?.();
// doAbort 不是由通讯管控 onloadend. 需要手动处理. 排程在下一个 microTask 避免影响 Abort 流程
Promise.resolve({ ...data, type: "loadend" }).then(doLoadEnd);
}
doAbort = null;
};
Expand Down Expand Up @@ -543,8 +565,17 @@ export function GM_xmlhttpRequest(
error: message,
});
reqDone = true;
retPromiseReject?.(message);
refCleanup?.();
// 不要进行 refCleanup !要等待最后的 onloadend
// refCleanup?.();

// 此错误多为 API 非正常执行,估计不会有 loadend 触发。见 Aborted 处理
Promise.resolve({
error: "loadend",
responseHeaders: "",
readyState: 0,
status: 0,
statusText: "",
} as TXhrCallBackArg).then(doLoadEnd);
}
return;
}
Expand Down Expand Up @@ -621,18 +652,7 @@ export function GM_xmlhttpRequest(
details.onload?.(makeXHRCallbackParam?.(data) ?? {});
break;
case "onloadend": {
reqDone = true;
responseText = false;
finalResultBuffers = null;
finalResultText = null;
const xhrResponse = makeXHRCallbackParam?.(data) ?? {};
details.onloadend?.(xhrResponse);
if (errorOccur === null) {
retPromiseResolve?.(xhrResponse);
} else {
retPromiseReject?.(errorOccur);
}
refCleanup?.();
doLoadEnd(data);
break;
}
case "onloadstart":
Expand Down Expand Up @@ -669,7 +689,8 @@ export function GM_xmlhttpRequest(
errorOccur = "TimeoutError";
details.ontimeout?.(makeXHRCallbackParam?.(data) ?? {});
reqDone = true;
refCleanup?.();
// 不要进行 refCleanup !要等待最后的 onloadend
// refCleanup?.();
}
break;
case "onerror":
Expand All @@ -678,7 +699,8 @@ export function GM_xmlhttpRequest(
errorOccur = data.error;
details.onerror?.((makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError);
reqDone = true;
refCleanup?.();
// 不要进行 refCleanup !要等待最后的 onloadend
// refCleanup?.();
}
break;
case "onabort":
Expand Down Expand Up @@ -715,7 +737,7 @@ export function GM_xmlhttpRequest(
readyState: 0,
status: 0,
statusText: "",
}) as GMXHRResponseType;
} as TXhrCallBackArg);
reqDone = true;
}
},
Expand Down
48 changes: 35 additions & 13 deletions src/app/service/service_worker/gm_api/gm_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import { ScriptDAO } from "@App/app/repo/scripts";
import { type IGetSender, type Group, GetSenderType } from "@Packages/message/server";
import type { ExtMessageSender, MessageSend, TMessageCommAction } from "@Packages/message/types";
import type { ExtMessageSender, MessageConnect, MessageSend, TMessageCommAction } from "@Packages/message/types";
import { connect, sendMessage } from "@Packages/message/client";
import type { IMessageQueue } from "@Packages/message/message_queue";
import { type ValueService } from "@App/app/service/service_worker/value";
Expand Down Expand Up @@ -762,18 +762,38 @@ export default class GMApi {
if (!msgConn) {
throw new Error("GM_xmlhttpRequest ERROR: msgConn is undefined");
}
const throwErrorFn = (error: string) => {
msgConn.sendMessage({
action: "onerror",
data: {
status: 0,
responseHeaders: "",
error: error,
readyState: 4, // ERROR. DONE.
},
});
return new Error(error);
};
// conn 为 nested scope 内 local 存取
let throwErrorFn: ((error: string) => Error) | null = ((conn: MessageConnect | null) => {
let errorOccur: string | null = null;
const doLoadEnd = () => {
conn?.sendMessage({
action: "onloadend",
data: {
status: 0,
responseHeaders: "",
error: errorOccur,
readyState: 4, // ERROR. DONE.
},
});
conn?.disconnect(); // 断开连结
conn = null; // 释放
};
return (error: string) => {
errorOccur = error;
conn?.sendMessage({
action: "onerror",
data: {
status: 0,
responseHeaders: "",
error: errorOccur,
readyState: 4, // ERROR. DONE.
},
});
// throwErrorFn 不是由通讯管控 onloadend. 需要手动处理. 排程在下一个 microTask 避免影响 throw Error 流程
Promise.resolve().then(doLoadEnd);
return new Error(errorOccur);
};
})(msgConn);
const details = request.params[0];
if (!details) {
throw throwErrorFn("param is failed");
Expand Down Expand Up @@ -819,6 +839,8 @@ export default class GMApi {
metadata[i18next.t("request_domain")] = url.hostname;
metadata[i18next.t("request_url")] = details.url;

throwErrorFn = null; // 确保 GC 可以释放 conn

return {
permission: "cors",
permissionValue: url.hostname,
Expand Down
Loading