From 0600fb0356050e0fbde35ab8d7be979f9a101cf0 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 14 Feb 2026 02:25:45 +0900 Subject: [PATCH 1/9] fix memory leakage & object properties exposure & TTP xml parse fallback to null --- src/app/service/content/gm_api/gm_xhr.ts | 230 ++++++++++++++--------- 1 file changed, 146 insertions(+), 84 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 276bea83f..5576beb22 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -111,6 +111,43 @@ const getMimeType = (contentType: string) => { const docParseTypes = new Set(["application/xhtml+xml", "application/xml", "image/svg+xml", "text/html", "text/xml"]); +const retStateFnMap = new WeakMap, RetStateFnRecord>(); + +interface RetStateFnRecord { + getResponseText(): string | undefined; + getResponseXML(): Document | null | undefined; + getResponse(): string | GMXhrResponseObjectType | null | undefined; +} + +// 对齐 TM, getter属性 enumerable=false 及 configurable=false +// 这影响 Object.assign({}, response) 的行为 +const xhrResponseGetters = { + response: { + get() { + const retTemp = retStateFnMap.get(this); + return retTemp?.getResponse(); + }, + enumerable: false, + configurable: false, + }, + responseXML: { + get() { + const retTemp = retStateFnMap.get(this); + return retTemp?.getResponseXML(); + }, + enumerable: false, + configurable: false, + }, + responseText: { + get() { + const retTemp = retStateFnMap.get(this); + return retTemp?.getResponseText(); + }, + enumerable: false, + configurable: false, + }, +}; + export function GM_xmlhttpRequest( a: GMApi, details: GMTypes.XHRDetails, @@ -317,6 +354,7 @@ export function GM_xmlhttpRequest( toString: () => "[object Object]", // follow TM } as GMXHRResponseType; let retParam: GMXHRResponseType; + let addGetters = false; if (resError) { retParam = { ...responseTypeDef, @@ -334,91 +372,9 @@ export function GM_xmlhttpRequest( }; if (allowResponse) { // 依照 TM 的规则:当 readyState 不等于 4 时,回应中不会有 response、responseXML 或 responseText。 + addGetters = true; retParam = { ...retParamBase, - get response() { - if (response === false) { - // 注: isStreamResponse 为 true 时 response 不会为 false - switch (responseTypeOriginal) { - case "json": { - const text = this.responseText; - let o = undefined; - if (text) { - try { - o = Native.jsonParse(text); - } catch { - // ignored - } - } - response = o; // TM兼容 -> o : object | undefined - break; - } - case "document": { - response = this.responseXML; - break; - } - case "arraybuffer": { - finalResultBuffers ||= concatUint8(resultBuffers); - const full = finalResultBuffers; - response = full.buffer; // ArrayBuffer - break; - } - case "blob": { - finalResultBuffers ||= concatUint8(resultBuffers); - const full = finalResultBuffers; - const type = res.contentType || "application/octet-stream"; - response = new Blob([full], { type }); // Blob - break; - } - default: { - // text - response = `${this.responseText}`; - break; - } - } - if (reqDone) { - resultTexts.length = 0; - resultBuffers.length = 0; - } - } - if (responseTypeOriginal === "json" && response === null) { - response = undefined; // TM不使用null,使用undefined - } - return response as string | GMXhrResponseObjectType | null | undefined; - }, - get responseXML() { - if (responseXML === false) { - // 注: isStreamResponse 为 true 时 responseXML 不会为 false - const text = this.responseText; - const mime = getMimeType(res.contentType); - const parseType = docParseTypes.has(mime) ? (mime as DOMParserSupportedType) : "text/xml"; - if (text) { - responseXML = new DOMParser().parseFromString(text, parseType); - } - } - return responseXML as Document | null | undefined; - }, - get responseText() { - if (responseText === false) { - // 注: isStreamResponse 为 true 时 responseText 不会为 false - if (resultType === ChunkResponseCode.UINT8_ARRAY_BUFFER) { - finalResultBuffers ||= concatUint8(resultBuffers); - const buf = finalResultBuffers.buffer as ArrayBuffer; - const decoder = new TextDecoder("utf-8"); - const text = decoder.decode(buf); - responseText = text; - } else { - // resultType === ChunkResponseCode.STRING - if (finalResultText === null) finalResultText = `${resultTexts.join("")}`; - responseText = finalResultText; - } - if (reqDone) { - resultTexts.length = 0; - resultBuffers.length = 0; - } - } - return responseText as string | undefined; - }, }; } else { retParam = retParamBase; @@ -430,7 +386,113 @@ export function GM_xmlhttpRequest( if (typeof contentContext !== "undefined") { retParam.context = contentContext; } - return retParam; + + let descriptors: ReturnType> = { + ...Object.getOwnPropertyDescriptors(retParam), + }; + let retTemp: RetStateFnRecord | null = null; + if (addGetters) { + // 外部没引用 retParamObject 时,retTemp 会被自动GC + retTemp = { + getResponse() { + if (response === false) { + // 注: isStreamResponse 为 true 时 response 不会为 false + switch (responseTypeOriginal) { + case "json": { + const text = this.getResponseText(); + let o = undefined; + if (text) { + try { + o = Native.jsonParse(text); + } catch { + // ignored + } + } + response = o; // TM兼容 -> o : object | undefined + break; + } + case "document": { + response = this.getResponseXML(); + break; + } + case "arraybuffer": { + finalResultBuffers ||= concatUint8(resultBuffers); + const full = finalResultBuffers; + response = full.buffer; // ArrayBuffer + break; + } + case "blob": { + finalResultBuffers ||= concatUint8(resultBuffers); + const full = finalResultBuffers; + const type = res.contentType || "application/octet-stream"; + response = new Blob([full], { type }); // Blob + break; + } + default: { + // text + response = `${this.getResponseText()}`; + break; + } + } + if (reqDone) { + resultTexts.length = 0; + resultBuffers.length = 0; + } + } + if (responseTypeOriginal === "json" && response === null) { + response = undefined; // TM不使用null,使用undefined + } + return response as string | GMXhrResponseObjectType | null | undefined; + }, + getResponseXML() { + if (responseXML === false) { + // 注: isStreamResponse 为 true 时 responseXML 不会为 false + const text = this.getResponseText(); + const mime = getMimeType(res.contentType); + const parseType = docParseTypes.has(mime) ? (mime as DOMParserSupportedType) : "text/xml"; + if (text) { + try { + responseXML = new DOMParser().parseFromString(text, parseType); + } catch (e) { + // 对齐 TM 处理。Trusted Type Policy受限制时返回 null + responseXML = null; + console.error(e); + } + } + } + return responseXML as Document | null | undefined; + }, + getResponseText() { + if (responseText === false) { + // 注: isStreamResponse 为 true 时 responseText 不会为 false + if (resultType === ChunkResponseCode.UINT8_ARRAY_BUFFER) { + finalResultBuffers ||= concatUint8(resultBuffers); + const buf = finalResultBuffers.buffer as ArrayBuffer; + const decoder = new TextDecoder("utf-8"); + const text = decoder.decode(buf); + responseText = text; + } else { + // resultType === ChunkResponseCode.STRING + if (finalResultText === null) finalResultText = `${resultTexts.join("")}`; + responseText = finalResultText; + } + if (reqDone) { + resultTexts.length = 0; + resultBuffers.length = 0; + } + } + return responseText as string | undefined; + }, + }; + descriptors = { + ...descriptors, + ...xhrResponseGetters, + }; + } + // 对齐 TM, res.constructor = undefined, res.__proto__ = undefined + const retParamObject: GMXHRResponseType = Object.create(null, descriptors); + if (retTemp) retStateFnMap.set(retParamObject, retTemp); + return retParamObject; }; let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; doAbort = (data: any) => { From 721b2562ba5dbb4aa9fd3e3302337e62103e6ef7 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 14 Feb 2026 09:06:01 +0900 Subject: [PATCH 2/9] example/tests/gm_xhr_test.js v1.2.2 --- example/tests/gm_xhr_test.js | 78 +++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js index 465284626..ced4ca9e6 100644 --- a/example/tests/gm_xhr_test.js +++ b/example/tests/gm_xhr_test.js @@ -1,7 +1,7 @@ // ==UserScript== // @name GM_xmlhttpRequest Exhaustive Test Harness v3 // @namespace tm-gmxhr-test -// @version 1.2.1 +// @version 1.2.2 // @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 @@ -273,6 +273,7 @@ const enableTool = true; assertEq(res.responseText, decodedBase64, "responseText ok"); assertEq(res.response, decodedBase64, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -289,6 +290,7 @@ const enableTool = true; assertEq(res.responseText, decodedBase64, "responseText ok"); assertEq(res.response, decodedBase64, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -305,6 +307,7 @@ const enableTool = true; assertEq(res.responseText, decodedBase64, "responseText ok"); assertEq(res.response, decodedBase64, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -322,6 +325,7 @@ const enableTool = true; assertEq(res.responseText, decodedBase64, "responseText ok"); assertEq(res.response, undefined, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -338,6 +342,7 @@ const enableTool = true; assertEq(res.responseText, decodedBase64, "responseText ok"); assertEq(res.response instanceof XMLDocument, true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -354,6 +359,7 @@ const enableTool = true; assertEq(res.responseText, undefined, "responseText ok"); assertEq(res.response instanceof ReadableStream, true, "response ok"); assertEq(res.responseXML, undefined, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -370,6 +376,7 @@ const enableTool = true; assertEq(res.responseText, decodedBase64, "responseText ok"); assertEq(res.response instanceof ArrayBuffer, true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -386,6 +393,7 @@ const enableTool = true; assertEq(res.responseText, decodedBase64, "responseText ok"); assertEq(res.response instanceof Blob, true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -401,6 +409,7 @@ const enableTool = true; assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); assertEq(`${res.response}`.includes('"code": 200'), true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -417,6 +426,7 @@ const enableTool = true; assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); assertEq(`${res.response}`.includes('"code": 200'), true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -433,6 +443,7 @@ const enableTool = true; assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); assertEq(`${res.response}`.includes('"code": 200'), true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -449,6 +460,7 @@ const enableTool = true; assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); assertEq(typeof res.response === "object" && res.response?.code === 200, true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -465,6 +477,7 @@ const enableTool = true; assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); assertEq(res.response instanceof XMLDocument, true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -481,6 +494,7 @@ const enableTool = true; assertEq(res.responseText, undefined, "responseText ok"); assertEq(res.response instanceof ReadableStream, true, "response ok"); assertEq(res.responseXML, undefined, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -497,6 +511,7 @@ const enableTool = true; assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); assertEq(res.response instanceof ArrayBuffer, true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -513,6 +528,7 @@ const enableTool = true; assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); assertEq(res.response instanceof Blob, true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -528,6 +544,7 @@ const enableTool = true; assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); assertEq(res.response, res.responseText, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -544,6 +561,7 @@ const enableTool = true; assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); assertEq(res.response, res.responseText, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -560,6 +578,7 @@ const enableTool = true; assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); assertEq(res.response, res.responseText, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -576,6 +595,7 @@ const enableTool = true; assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); assertEq(res.response, undefined, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -592,6 +612,7 @@ const enableTool = true; assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); assertEq(res.response instanceof XMLDocument, true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -608,6 +629,7 @@ const enableTool = true; assertEq(res.responseText, undefined, "responseText ok"); assertEq(res.response instanceof ReadableStream, true, "response ok"); assertEq(res.responseXML, undefined, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -624,6 +646,7 @@ const enableTool = true; assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); assertEq(res.response instanceof ArrayBuffer, true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -640,6 +663,7 @@ const enableTool = true; assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); assertEq(res.response instanceof Blob, true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -659,6 +683,7 @@ const enableTool = true; const hdrs = body.headers || {}; assertEq(hdrs["X-Custom"] || hdrs["x-custom"], "Hello", "custom header echo"); assertEq(res.finalUrl, url, "finalUrl matches"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -673,6 +698,7 @@ const enableTool = true; }); assertEq(res.status, 200, "status after redirect is 200"); assertEq(res.finalUrl, target, "finalUrl is redirected target"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -688,6 +714,7 @@ const enableTool = true; }); assertEq(res.status, 200, "status after redirect is 200"); assertEq(res.finalUrl, target, "finalUrl is redirected target"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -713,6 +740,7 @@ const enableTool = true; assertEq(e?.res?.status, 408, "statusCode ok"); assertEq(!e?.res?.finalUrl, true, "!finalUrl ok"); assertEq(e?.res?.responseHeaders, "", "responseHeaders ok"); + assertEq(objectProps(e?.res), "ok", "Object Props OK"); } }, }, @@ -734,6 +762,7 @@ const enableTool = true; assertEq(res?.status, 301, "status is 301"); assertEq(res?.finalUrl, url, "finalUrl is original url"); assertEq(typeof res?.responseHeaders === "string" && res?.responseHeaders !== "", true, "responseHeaders ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -751,6 +780,7 @@ const enableTool = true; assertEq(res.status, 200); assertEq((body.form || {}).a, "1", "form a"); assertEq((body.form || {}).b, "two", "form b"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -767,6 +797,7 @@ const enableTool = true; const body = JSON.parse(res.responseText); assertEq(res.status, 200); assertDeepEq(body.json, payload, "JSON echo matches"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -783,6 +814,7 @@ const enableTool = true; const body = JSON.parse(res.responseText); assertEq(res.status, 200); assert(body.data && body.data.length > 0, "server received some data"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -803,6 +835,7 @@ const enableTool = true; assert(res.response instanceof ArrayBuffer, "arraybuffer present"); assertEq(res.response.byteLength, size, "byte length matches"); assert(progressCounter >= 1, "progressCounter >= 1"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -825,6 +858,7 @@ const enableTool = true; const buf = await res.response.arrayBuffer(); assertEq(buf.byteLength, size, "byte length matches"); assert(progressCounter >= 1, "progressCounter >= 1"); + assertEq(objectProps(res), "ok", "Object Props OK"); // Do not assert image MIME; httpbun returns octet-stream here. }, }, @@ -841,6 +875,7 @@ const enableTool = true; assertEq(res.status, 200); assert(res.response && typeof res.response === "object", "parsed JSON object"); assert(res.response.origin, "has JSON fields"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -854,6 +889,7 @@ const enableTool = true; }); assertEq(res.status, 200); assert(typeof res.responseText === "string" && res.responseText.length > 0, "responseText available"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -902,6 +938,7 @@ const enableTool = true; // `progress` is guaranteed to fire only in the Fetch API. assert(fetch ? lastLoaded > 0 : lastLoaded >= 0, "progress loaded captured"); assert(!response, "no response"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -945,6 +982,7 @@ const enableTool = true; // `progress` is guaranteed to fire only in the Fetch API. assert(fetch ? lastLoaded > 0 : lastLoaded >= 0, "progress loaded captured"); assert(response instanceof ReadableStream && typeof response.getReader === "function", "response"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -958,6 +996,7 @@ const enableTool = true; assertEq(res.status, 200); assert((res.responseText || "")?.length > 0, "body for HEAD"); assert(typeof res.responseHeaders === "string", "response headers present"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -971,6 +1010,7 @@ const enableTool = true; assertEq(res.status, 200); assertEq(res.responseText || "", "", "no body for HEAD"); assert(typeof res.responseHeaders === "string", "response headers present"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -983,6 +1023,7 @@ const enableTool = true; }); // httpbun commonly returns 200 for OPTIONS assert(res.status === 200 || res.status === 204, "200/204 on OPTIONS"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -996,6 +1037,7 @@ const enableTool = true; assertEq(res.status, 200); const body = JSON.parse(res.responseText); assertEq(body.method, "DELETE", "server saw DELETE"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1022,6 +1064,7 @@ const enableTool = true; const body = JSON.parse(res.responseText); const cookieABC = body.cookies.abc; assertEq(cookieABC, "123", "cookie abc=123"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1037,6 +1080,7 @@ const enableTool = true; const body = JSON.parse(res.responseText); const cookies = body.headers.Cookie || body.headers.cookie; assert(!`${cookies}`.includes("abc=123"), "no Cookie header when anonymous"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1051,6 +1095,7 @@ const enableTool = true; const body = JSON.parse(res.responseText); const cookies = body.headers.Cookie || body.headers.cookie; assert(`${cookies}`.includes("abc=123"), "Cookie header"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1090,6 +1135,7 @@ const enableTool = true; const body = JSON.parse(res.responseText); const cookies = body.headers.Cookie || body.headers.cookie; assert(!cookies, "no Cookie header when anonymous"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1120,6 +1166,7 @@ const enableTool = true; const body = JSON.parse(res.responseText); assertEq(body.authenticated, true, "authenticated true"); assertEq(body.user, "user", "user echoed"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1131,6 +1178,7 @@ const enableTool = true; fetch, }); assertEq(res.status, 418, "418 I'm a teapot"); + assertEq(objectProps(res), "ok", "Object Props OK"); // Still triggers onload, not onerror }, }, @@ -1144,6 +1192,7 @@ const enableTool = true; fetch, }); assert([200, 405].includes(res.status), "200 or 405 depending on server handling"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1173,6 +1222,7 @@ const enableTool = true; true, "Refused to connect to ..." ); + assertEq(objectProps(e.res), "ok", "Object Props OK"); } }, }, @@ -1202,6 +1252,7 @@ const enableTool = true; true, "Refused to connect to ..." ); + assertEq(objectProps(e.res), "ok", "Object Props OK"); } }, }, @@ -1222,6 +1273,7 @@ const enableTool = true; assertEq(e.res.responseXML, undefined, "responseXML undefined"); assertEq(e.res.responseHeaders, "", 'responseHeaders ""'); assertEq(e.res.readyState, 4, "readyState 4"); + assertEq(objectProps(e.res), "ok", "Object Props OK"); } }, }, @@ -1261,6 +1313,7 @@ const enableTool = true; assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); assertEq(typeof res.response === "object" && res.response?.code === 200, true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1282,6 +1335,7 @@ const enableTool = true; assertEq(typeof res.response === "object" && res.response?.code === 200, true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); assertDeepEq(readyStateList, fetch ? [2, 4] : [1, 2, 3, 4], "status 200"); + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1569,6 +1623,7 @@ const enableTool = true; for (let i = 0; i < lines.length - 1; i++) { assert(lines[i].length > 0, `header line ${i} present`); } + assertEq(objectProps(res), "ok", "Object Props OK"); }, }, ]; @@ -1597,6 +1652,27 @@ const enableTool = true; const line = lines.find((l) => l.toLowerCase().startsWith(key.toLowerCase() + ":")); return line ? line.split(":").slice(1).join(":").trim() : ""; } + function objectProps(o) { + if (!o || typeof o !== "object") return "not an object"; + let z, oD, zD; + try { + z = Object.assign({}, o); + } catch { + return "Object.assign failed"; + } + try { + oD = JSON.stringify(o); + } catch { + return "JSON.stringify failed"; + } + try { + zD = JSON.stringify(z); + } catch { + return "JSON.stringify failed"; + } + if (oD !== zD) return "Object Props Failed"; + return "ok"; + } // ---------- Runner ---------- async function runAll() { From 565faedce12033f013e563da4b805a0607fbb383 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 14 Feb 2026 09:31:03 +0900 Subject: [PATCH 3/9] [example/tests/gm_xhr_test.js] add non-primitive value expose check --- example/tests/gm_xhr_test.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js index ced4ca9e6..31e07b0bb 100644 --- a/example/tests/gm_xhr_test.js +++ b/example/tests/gm_xhr_test.js @@ -1660,6 +1660,11 @@ const enableTool = true; } catch { return "Object.assign failed"; } + // accept null / "" / undefined for normal/failed/fetch_normal/fetch_failed XHR + // non-empty text (still primitive) can be also accepted. (common in xhr error case) + if (typeof (z.response ?? "") !== "string") return "non-primitive response value exposed"; + if (typeof (z.responseText ?? "") !== "string") return "non-primitive responseText value exposed"; + if (typeof (z.responseXML ?? "") !== "string") return "non-primitive responseXML value exposed"; try { oD = JSON.stringify(o); } catch { From fe1a446b2f10f20257756adb7c00759e0de87d62 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:21:29 +0900 Subject: [PATCH 4/9] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E5=86=99=E6=B3=95=EF=BC=8C=E6=9C=89=E5=88=A9=E4=BA=8E=20GC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.ts | 788 +++++++++++------------ 1 file changed, 393 insertions(+), 395 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 5576beb22..b6c161669 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -185,7 +185,7 @@ export function GM_xmlhttpRequest( details.method = `${details.method}`.toUpperCase() as typeof details.method; } - const param: GMSend.XHRDetails = { + let param: GMSend.XHRDetails | null = { method: details.method, timeout: details.timeout, url: "", @@ -208,7 +208,7 @@ export function GM_xmlhttpRequest( let connect: MessageConnect | null; const responseTypeOriginal = details.responseType?.toLocaleLowerCase() || ""; let doAbort: any = null; - const handler = async () => { + (async () => { const [urlResolved, dataResolved] = await Promise.all([urlPromiseLike, dataPromise]); const u = new URL(urlResolved, window.location.href); param.url = u.href; @@ -257,435 +257,433 @@ export function GM_xmlhttpRequest( // 一般 GM_xmlhttpRequest,呼叫 SW 的 GM_xmlhttpRequest connectMessage = a.connect("GM_xmlhttpRequest", [param]); } - connectMessage.then((con) => { - // 注意。在此 callback 里,不应直接存取 param, 否则会影响 GC - connect = con; - const resultTexts = [] as string[]; // 函数参考清掉后,变数会被GC - const resultBuffers = [] as Uint8Array[]; // 函数参考清掉后,变数会被GC - let finalResultBuffers: Uint8Array | null = null; // 函数参考清掉后,变数会被GC - let finalResultText: string | null = null; // 函数参考清掉后,变数会被GC - let isEmptyResult = true; - const asyncTaskId = `${Date.now()}:${Math.random()}`; - let lastStateAndCode = ""; - let allowResponse = false; // readyState 未达至 4 (DONE) 时,不提供 response, responseText, responseXML + param = null; // GC + connect = await connectMessage; - let errorOccur: string | null = null; - let response: unknown = null; - let responseText: string | undefined | false = ""; - let responseXML: unknown = null; - let resultType: ChunkResponseCode = ChunkResponseCode.NONE; - if (readerStream) { - allowResponse = true; // TM 特殊处理。 fetchXhr stream 无视 readyState - response = readerStream; - responseText = undefined; // TM兼容 - responseXML = undefined; // TM兼容 - readerStream = undefined; - } + const resultTexts = [] as string[]; // 函数参考清掉后,变数会被GC + const resultBuffers = [] as Uint8Array[]; // 函数参考清掉后,变数会被GC + let finalResultBuffers: Uint8Array | null = null; // 函数参考清掉后,变数会被GC + let finalResultText: string | null = null; // 函数参考清掉后,变数会被GC + let isEmptyResult = true; + const asyncTaskId = `${Date.now()}:${Math.random()}`; + let lastStateAndCode = ""; + let allowResponse = false; // readyState 未达至 4 (DONE) 时,不提供 response, responseText, responseXML - let refCleanup: (() => void) | null = () => { - // 清掉函数参考,避免各变数参考无法GC - makeXHRCallbackParam = null; - onMessageHandler = null; - doAbort = null; - refCleanup = null; - connect = null; - }; + let errorOccur: string | null = null; + let response: unknown = null; + let responseText: string | undefined | false = ""; + let responseXML: unknown = null; + let resultType: ChunkResponseCode = ChunkResponseCode.NONE; + if (readerStream) { + allowResponse = true; // TM 特殊处理。 fetchXhr stream 无视 readyState + response = readerStream; + responseText = undefined; // TM兼容 + responseXML = undefined; // TM兼容 + readerStream = undefined; + } - const markResponseDirty = () => { - // 标记内部变数需要重新读取 - // reqDone 或 readerStream 的情况,不需要重置 - if (!reqDone && !isStreamResponse) { - response = false; - responseText = false; - responseXML = false; - finalResultText = null; - finalResultBuffers = null; - } - }; + let refCleanup: (() => void) | null = () => { + // 清掉函数参考,避免各变数参考无法GC + makeXHRCallbackParam = null; + onMessageHandler = null; + doAbort = null; + refCleanup = null; + connect = null; + }; - const makeXHRCallbackParam_ = ( - res: { - // - finalUrl: string; - readyState: ReadyStateCode; - status: number; - statusText: string; - responseHeaders: string; - error?: string; - // - useFetch: boolean; - eventType: string; - ok: boolean; - contentType: string; - } & Record - ) => { - if ((res.readyState === 4 || reqDone) && res.eventType !== "progress") allowResponse = true; - let resError: Record | null = null; - if ( - (typeof res.error === "string" && - (res.status === 0 || res.status >= 300 || res.status < 200) && - !res.statusText && - isEmptyResult) || - res.error === "aborted" - ) { - resError = { - error: res.error as string, - readyState: res.readyState as ReadyStateCode, - // responseType: responseType as "text" | "arraybuffer" | "blob" | "json" | "document" | "stream" | "", - response: null, - responseHeaders: res.responseHeaders as string, - responseText: "", - status: res.status as number, - statusText: "", - }; - } - const responseTypeDef = { - DONE: ReadyStateCode.DONE, - HEADERS_RECEIVED: ReadyStateCode.HEADERS_RECEIVED, - LOADING: ReadyStateCode.LOADING, - OPENED: ReadyStateCode.OPENED, - UNSENT: ReadyStateCode.UNSENT, - RESPONSE_TYPE_TEXT: "text", - RESPONSE_TYPE_ARRAYBUFFER: "arraybuffer", - RESPONSE_TYPE_BLOB: "blob", - RESPONSE_TYPE_DOCUMENT: "document", - RESPONSE_TYPE_JSON: "json", - RESPONSE_TYPE_STREAM: "stream", - toString: () => "[object Object]", // follow TM + const markResponseDirty = () => { + // 标记内部变数需要重新读取 + // reqDone 或 readerStream 的情况,不需要重置 + if (!reqDone && !isStreamResponse) { + response = false; + responseText = false; + responseXML = false; + finalResultText = null; + finalResultBuffers = null; + } + }; + + const makeXHRCallbackParam_ = ( + res: { + // + finalUrl: string; + readyState: ReadyStateCode; + status: number; + statusText: string; + responseHeaders: string; + error?: string; + // + useFetch: boolean; + eventType: string; + ok: boolean; + contentType: string; + } & Record + ) => { + if ((res.readyState === 4 || reqDone) && res.eventType !== "progress") allowResponse = true; + let resError: Record | null = null; + if ( + (typeof res.error === "string" && + (res.status === 0 || res.status >= 300 || res.status < 200) && + !res.statusText && + isEmptyResult) || + res.error === "aborted" + ) { + resError = { + error: res.error as string, + readyState: res.readyState as ReadyStateCode, + // responseType: responseType as "text" | "arraybuffer" | "blob" | "json" | "document" | "stream" | "", + response: null, + responseHeaders: res.responseHeaders as string, + responseText: "", + status: res.status as number, + statusText: "", + }; + } + const responseTypeDef = { + DONE: ReadyStateCode.DONE, + HEADERS_RECEIVED: ReadyStateCode.HEADERS_RECEIVED, + LOADING: ReadyStateCode.LOADING, + OPENED: ReadyStateCode.OPENED, + UNSENT: ReadyStateCode.UNSENT, + RESPONSE_TYPE_TEXT: "text", + RESPONSE_TYPE_ARRAYBUFFER: "arraybuffer", + RESPONSE_TYPE_BLOB: "blob", + RESPONSE_TYPE_DOCUMENT: "document", + RESPONSE_TYPE_JSON: "json", + RESPONSE_TYPE_STREAM: "stream", + toString: () => "[object Object]", // follow TM + } as GMXHRResponseType; + let retParam: GMXHRResponseType; + let addGetters = false; + if (resError) { + retParam = { + ...responseTypeDef, + ...resError, } as GMXHRResponseType; - let retParam: GMXHRResponseType; - let addGetters = false; - if (resError) { + } else { + const retParamBase = { + ...responseTypeDef, + finalUrl: res.finalUrl as string, + readyState: res.readyState as ReadyStateCode, + status: res.status as number, + statusText: res.statusText as string, + responseHeaders: res.responseHeaders as string, + responseType: responseTypeOriginal as "text" | "arraybuffer" | "blob" | "json" | "document" | "stream" | "", + }; + if (allowResponse) { + // 依照 TM 的规则:当 readyState 不等于 4 时,回应中不会有 response、responseXML 或 responseText。 + addGetters = true; retParam = { - ...responseTypeDef, - ...resError, - } as GMXHRResponseType; - } else { - const retParamBase = { - ...responseTypeDef, - finalUrl: res.finalUrl as string, - readyState: res.readyState as ReadyStateCode, - status: res.status as number, - statusText: res.statusText as string, - responseHeaders: res.responseHeaders as string, - responseType: responseTypeOriginal as "text" | "arraybuffer" | "blob" | "json" | "document" | "stream" | "", + ...retParamBase, }; - if (allowResponse) { - // 依照 TM 的规则:当 readyState 不等于 4 时,回应中不会有 response、responseXML 或 responseText。 - addGetters = true; - retParam = { - ...retParamBase, - }; - } else { - retParam = retParamBase; - } - if (res.error) { - retParam.error = res.error; - } + } else { + retParam = retParamBase; } - if (typeof contentContext !== "undefined") { - retParam.context = contentContext; + if (res.error) { + retParam.error = res.error; } + } + if (typeof contentContext !== "undefined") { + retParam.context = contentContext; + } - let descriptors: ReturnType> = { - ...Object.getOwnPropertyDescriptors(retParam), - }; - let retTemp: RetStateFnRecord | null = null; - if (addGetters) { - // 外部没引用 retParamObject 时,retTemp 会被自动GC - retTemp = { - getResponse() { - if (response === false) { - // 注: isStreamResponse 为 true 时 response 不会为 false - switch (responseTypeOriginal) { - case "json": { - const text = this.getResponseText(); - let o = undefined; - if (text) { - try { - o = Native.jsonParse(text); - } catch { - // ignored - } + let descriptors: ReturnType> = { + ...Object.getOwnPropertyDescriptors(retParam), + }; + let retTemp: RetStateFnRecord | null = null; + if (addGetters) { + // 外部没引用 retParamObject 时,retTemp 会被自动GC + retTemp = { + getResponse() { + if (response === false) { + // 注: isStreamResponse 为 true 时 response 不会为 false + switch (responseTypeOriginal) { + case "json": { + const text = this.getResponseText(); + let o = undefined; + if (text) { + try { + o = Native.jsonParse(text); + } catch { + // ignored } - response = o; // TM兼容 -> o : object | undefined - break; - } - case "document": { - response = this.getResponseXML(); - break; - } - case "arraybuffer": { - finalResultBuffers ||= concatUint8(resultBuffers); - const full = finalResultBuffers; - response = full.buffer; // ArrayBuffer - break; - } - case "blob": { - finalResultBuffers ||= concatUint8(resultBuffers); - const full = finalResultBuffers; - const type = res.contentType || "application/octet-stream"; - response = new Blob([full], { type }); // Blob - break; - } - default: { - // text - response = `${this.getResponseText()}`; - break; } + response = o; // TM兼容 -> o : object | undefined + break; } - if (reqDone) { - resultTexts.length = 0; - resultBuffers.length = 0; + case "document": { + response = this.getResponseXML(); + break; } - } - if (responseTypeOriginal === "json" && response === null) { - response = undefined; // TM不使用null,使用undefined - } - return response as string | GMXhrResponseObjectType | null | undefined; - }, - getResponseXML() { - if (responseXML === false) { - // 注: isStreamResponse 为 true 时 responseXML 不会为 false - const text = this.getResponseText(); - const mime = getMimeType(res.contentType); - const parseType = docParseTypes.has(mime) ? (mime as DOMParserSupportedType) : "text/xml"; - if (text) { - try { - responseXML = new DOMParser().parseFromString(text, parseType); - } catch (e) { - // 对齐 TM 处理。Trusted Type Policy受限制时返回 null - responseXML = null; - console.error(e); - } + case "arraybuffer": { + finalResultBuffers ||= concatUint8(resultBuffers); + const full = finalResultBuffers; + response = full.buffer; // ArrayBuffer + break; } - } - return responseXML as Document | null | undefined; - }, - getResponseText() { - if (responseText === false) { - // 注: isStreamResponse 为 true 时 responseText 不会为 false - if (resultType === ChunkResponseCode.UINT8_ARRAY_BUFFER) { + case "blob": { finalResultBuffers ||= concatUint8(resultBuffers); - const buf = finalResultBuffers.buffer as ArrayBuffer; - const decoder = new TextDecoder("utf-8"); - const text = decoder.decode(buf); - responseText = text; - } else { - // resultType === ChunkResponseCode.STRING - if (finalResultText === null) finalResultText = `${resultTexts.join("")}`; - responseText = finalResultText; + const full = finalResultBuffers; + const type = res.contentType || "application/octet-stream"; + response = new Blob([full], { type }); // Blob + break; } - if (reqDone) { - resultTexts.length = 0; - resultBuffers.length = 0; + default: { + // text + response = `${this.getResponseText()}`; + break; } } - return responseText as string | undefined; - }, - }; - descriptors = { - ...descriptors, - ...xhrResponseGetters, - }; - } - // 对齐 TM, res.constructor = undefined, res.__proto__ = undefined - const retParamObject: GMXHRResponseType = Object.create(null, descriptors); - if (retTemp) retStateFnMap.set(retParamObject, retTemp); - return retParamObject; - }; - let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; - doAbort = (data: any) => { - if (!reqDone) { - errorOccur = "AbortError"; - details.onabort?.(makeXHRCallbackParam?.(data) ?? {}); - reqDone = true; - refCleanup?.(); - } - doAbort = null; - }; - - let onMessageHandler: ((data: TMessage) => void) | null = (msgData: TMessage) => { - stackAsyncTask(asyncTaskId, async () => { - const data = msgData.data as Record & { - // - finalUrl: string; - readyState: ReadyStateCode; - status: number; - statusText: string; - responseHeaders: string; - // - useFetch: boolean; - eventType: string; - ok: boolean; - contentType: string; - error: undefined | string; - }; - if (msgData.code === -1) { - // 处理错误 - LoggerCore.logger().error("GM_xmlhttpRequest error", { - code: msgData.code, - message: msgData.message, - }); - details.onerror?.({ - readyState: ReadyStateCode.DONE, - error: msgData.message || "unknown", - }); - return; - } - // 处理返回 - switch (msgData.action) { - case "reset_chunk_arraybuffer": - case "reset_chunk_blob": - case "reset_chunk_buffer": { - if (reqDone || isStreamResponse) { - // 理论上不应发生,仅作为逻辑控制的保护。 - console.error("Invalid call of reset_chunk [buf]"); - break; - } - resultBuffers.length = 0; - isEmptyResult = true; - markResponseDirty(); - break; - } - case "reset_chunk_document": - case "reset_chunk_json": - case "reset_chunk_text": { - if (reqDone || isStreamResponse) { - // 理论上不应发生,仅作为逻辑控制的保护。 - console.error("Invalid call of reset_chunk [str]"); - break; + if (reqDone) { + resultTexts.length = 0; + resultBuffers.length = 0; } - resultTexts.length = 0; - isEmptyResult = true; - markResponseDirty(); - break; - } - case "append_chunk_stream": { - // by fetch_xhr, isStreamResponse = true - const d = msgData.data.chunk as string; - const u8 = base64ToUint8(d); - resultBuffers.push(u8); - isEmptyResult = false; - controller?.enqueue(base64ToUint8(d)); - resultType = ChunkResponseCode.READABLE_STREAM; - break; } - case "append_chunk_arraybuffer": - case "append_chunk_blob": - case "append_chunk_buffer": { - if (reqDone || isStreamResponse) { - // 理论上不应发生,仅作为逻辑控制的保护。 - console.error("Invalid call of append_chunk [buf]"); - break; - } - const d = msgData.data.chunk as string; - const u8 = base64ToUint8(d); - resultBuffers.push(u8); - isEmptyResult = false; - resultType = ChunkResponseCode.UINT8_ARRAY_BUFFER; - markResponseDirty(); - break; + if (responseTypeOriginal === "json" && response === null) { + response = undefined; // TM不使用null,使用undefined } - case "append_chunk_document": - case "append_chunk_json": - case "append_chunk_text": { - if (reqDone || isStreamResponse) { - // 理论上不应发生,仅作为逻辑控制的保护。 - console.error("Invalid call of append_chunk [str]"); - break; + return response as string | GMXhrResponseObjectType | null | undefined; + }, + getResponseXML() { + if (responseXML === false) { + // 注: isStreamResponse 为 true 时 responseXML 不会为 false + const text = this.getResponseText(); + const mime = getMimeType(res.contentType); + const parseType = docParseTypes.has(mime) ? (mime as DOMParserSupportedType) : "text/xml"; + if (text) { + try { + responseXML = new DOMParser().parseFromString(text, parseType); + } catch (e) { + // 对齐 TM 处理。Trusted Type Policy受限制时返回 null + responseXML = null; + console.error(e); + } } - const d = msgData.data.chunk as string; - resultTexts.push(d); - isEmptyResult = false; - resultType = ChunkResponseCode.STRING; - markResponseDirty(); - break; } - case "onload": - details.onload?.(makeXHRCallbackParam?.(data) ?? {}); - break; - case "onloadend": { - reqDone = true; - responseText = false; - finalResultBuffers = null; - finalResultText = null; - const xhrReponse = makeXHRCallbackParam?.(data) ?? {}; - details.onloadend?.(xhrReponse); - if (errorOccur === null) { - retPromiseResolve?.(xhrReponse); + return responseXML as Document | null | undefined; + }, + getResponseText() { + if (responseText === false) { + // 注: isStreamResponse 为 true 时 responseText 不会为 false + if (resultType === ChunkResponseCode.UINT8_ARRAY_BUFFER) { + finalResultBuffers ||= concatUint8(resultBuffers); + const buf = finalResultBuffers.buffer as ArrayBuffer; + const decoder = new TextDecoder("utf-8"); + const text = decoder.decode(buf); + responseText = text; } else { - retPromiseReject?.(errorOccur); + // resultType === ChunkResponseCode.STRING + if (finalResultText === null) finalResultText = `${resultTexts.join("")}`; + responseText = finalResultText; } - refCleanup?.(); - break; - } - case "onloadstart": - details.onloadstart?.(makeXHRCallbackParam?.(data) ?? {}); - break; - case "onprogress": { - if (details.onprogress) { - const res = { - ...(makeXHRCallbackParam?.(data) ?? {}), - lengthComputable: data.lengthComputable as boolean, - loaded: data.loaded as number, - total: data.total as number, - done: data.loaded, - totalSize: data.total, - }; - details.onprogress?.(res); + if (reqDone) { + resultTexts.length = 0; + resultBuffers.length = 0; } - break; } - case "onreadystatechange": { - // 避免xhr的readystatechange多次触发问题。见 https://github.com/violentmonkey/violentmonkey/issues/1862 - const curStateAndCode = `${data.readyState}:${data.status}`; - if (curStateAndCode === lastStateAndCode) return; - lastStateAndCode = curStateAndCode; - if (isStreamResponse && data.readyState === ReadyStateCode.DONE) { - // readable stream 的 controller 可以释放 - controller = undefined; // GC用 - } - details.onreadystatechange?.(makeXHRCallbackParam?.(data) ?? {}); + return responseText as string | undefined; + }, + }; + descriptors = { + ...descriptors, + ...xhrResponseGetters, + }; + } + // 对齐 TM, res.constructor = undefined, res.__proto__ = undefined + const retParamObject: GMXHRResponseType = Object.create(null, descriptors); + if (retTemp) retStateFnMap.set(retParamObject, retTemp); + return retParamObject; + }; + let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; + doAbort = (data: any) => { + if (!reqDone) { + errorOccur = "AbortError"; + details.onabort?.(makeXHRCallbackParam?.(data) ?? {}); + reqDone = true; + refCleanup?.(); + } + doAbort = null; + }; + + let onMessageHandler: ((data: TMessage) => void) | null = (msgData: TMessage) => { + stackAsyncTask(asyncTaskId, async () => { + const data = msgData.data as Record & { + // + finalUrl: string; + readyState: ReadyStateCode; + status: number; + statusText: string; + responseHeaders: string; + // + useFetch: boolean; + eventType: string; + ok: boolean; + contentType: string; + error: undefined | string; + }; + if (msgData.code === -1) { + // 处理错误 + LoggerCore.logger().error("GM_xmlhttpRequest error", { + code: msgData.code, + message: msgData.message, + }); + details.onerror?.({ + readyState: ReadyStateCode.DONE, + error: msgData.message || "unknown", + }); + return; + } + // 处理返回 + switch (msgData.action) { + case "reset_chunk_arraybuffer": + case "reset_chunk_blob": + case "reset_chunk_buffer": { + if (reqDone || isStreamResponse) { + // 理论上不应发生,仅作为逻辑控制的保护。 + console.error("Invalid call of reset_chunk [buf]"); break; } - case "ontimeout": - if (!reqDone) { - errorOccur = "TimeoutError"; - details.ontimeout?.(makeXHRCallbackParam?.(data) ?? {}); - reqDone = true; - refCleanup?.(); - } - break; - case "onerror": - if (!reqDone) { - data.error ||= "Unknown Error"; - errorOccur = data.error; - details.onerror?.((makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError); - reqDone = true; - refCleanup?.(); - } + resultBuffers.length = 0; + isEmptyResult = true; + markResponseDirty(); + break; + } + case "reset_chunk_document": + case "reset_chunk_json": + case "reset_chunk_text": { + if (reqDone || isStreamResponse) { + // 理论上不应发生,仅作为逻辑控制的保护。 + console.error("Invalid call of reset_chunk [str]"); break; - case "onabort": - doAbort?.(data); + } + resultTexts.length = 0; + isEmptyResult = true; + markResponseDirty(); + break; + } + case "append_chunk_stream": { + // by fetch_xhr, isStreamResponse = true + const d = msgData.data.chunk as string; + const u8 = base64ToUint8(d); + resultBuffers.push(u8); + isEmptyResult = false; + controller?.enqueue(base64ToUint8(d)); + resultType = ChunkResponseCode.READABLE_STREAM; + break; + } + case "append_chunk_arraybuffer": + case "append_chunk_blob": + case "append_chunk_buffer": { + if (reqDone || isStreamResponse) { + // 理论上不应发生,仅作为逻辑控制的保护。 + console.error("Invalid call of append_chunk [buf]"); break; - // case "onstream": - // controller?.enqueue(new Uint8Array(data)); - // break; - default: - LoggerCore.logger().warn("GM_xmlhttpRequest resp is error", { - data: msgData, - }); + } + const d = msgData.data.chunk as string; + const u8 = base64ToUint8(d); + resultBuffers.push(u8); + isEmptyResult = false; + resultType = ChunkResponseCode.UINT8_ARRAY_BUFFER; + markResponseDirty(); + break; + } + case "append_chunk_document": + case "append_chunk_json": + case "append_chunk_text": { + if (reqDone || isStreamResponse) { + // 理论上不应发生,仅作为逻辑控制的保护。 + console.error("Invalid call of append_chunk [str]"); break; + } + const d = msgData.data.chunk as string; + resultTexts.push(d); + isEmptyResult = false; + resultType = ChunkResponseCode.STRING; + markResponseDirty(); + break; } - }); - }; + case "onload": + details.onload?.(makeXHRCallbackParam?.(data) ?? {}); + break; + case "onloadend": { + reqDone = true; + responseText = false; + finalResultBuffers = null; + finalResultText = null; + const xhrReponse = makeXHRCallbackParam?.(data) ?? {}; + details.onloadend?.(xhrReponse); + if (errorOccur === null) { + retPromiseResolve?.(xhrReponse); + } else { + retPromiseReject?.(errorOccur); + } + refCleanup?.(); + break; + } + case "onloadstart": + details.onloadstart?.(makeXHRCallbackParam?.(data) ?? {}); + break; + case "onprogress": { + if (details.onprogress) { + const res = { + ...(makeXHRCallbackParam?.(data) ?? {}), + lengthComputable: data.lengthComputable as boolean, + loaded: data.loaded as number, + total: data.total as number, + done: data.loaded, + totalSize: data.total, + }; + details.onprogress?.(res); + } + break; + } + case "onreadystatechange": { + // 避免xhr的readystatechange多次触发问题。见 https://github.com/violentmonkey/violentmonkey/issues/1862 + const curStateAndCode = `${data.readyState}:${data.status}`; + if (curStateAndCode === lastStateAndCode) return; + lastStateAndCode = curStateAndCode; + if (isStreamResponse && data.readyState === ReadyStateCode.DONE) { + // readable stream 的 controller 可以释放 + controller = undefined; // GC用 + } + details.onreadystatechange?.(makeXHRCallbackParam?.(data) ?? {}); + break; + } + case "ontimeout": + if (!reqDone) { + errorOccur = "TimeoutError"; + details.ontimeout?.(makeXHRCallbackParam?.(data) ?? {}); + reqDone = true; + refCleanup?.(); + } + break; + case "onerror": + if (!reqDone) { + data.error ||= "Unknown Error"; + errorOccur = data.error; + details.onerror?.((makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError); + reqDone = true; + refCleanup?.(); + } + break; + case "onabort": + doAbort?.(data); + break; + // case "onstream": + // controller?.enqueue(new Uint8Array(data)); + // break; + default: + LoggerCore.logger().warn("GM_xmlhttpRequest resp is error", { + data: msgData, + }); + break; + } + }); + }; - connect?.onMessage((msgData) => onMessageHandler?.(msgData)); - }); - }; + connect?.onMessage((msgData) => onMessageHandler?.(msgData)); + })(); // 由于需要同步返回一个abort,但是一些操作是异步的,所以需要在这里处理 - handler(); return { retPromise, abort: () => { From 30795179493ed18db4eff3dbe586f515353f146d Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:00:55 +0900 Subject: [PATCH 5/9] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=9E=84=E9=80=A0?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=B8=80=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.ts | 224 ++++++++++++----------- 1 file changed, 113 insertions(+), 111 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index b6c161669..10651a6b6 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -303,6 +303,117 @@ export function GM_xmlhttpRequest( } }; + const makeRetTemp = (contentType: string) => { + return { + getResponse() { + if (response === false) { + // 注: isStreamResponse 为 true 时 response 不会为 false + switch (responseTypeOriginal) { + case "json": { + const text = this.getResponseText(); + let o = undefined; + if (text) { + try { + o = Native.jsonParse(text); + } catch { + // ignored + } + } + response = o; // TM兼容 -> o : object | undefined + break; + } + case "document": { + response = this.getResponseXML(); + break; + } + case "arraybuffer": { + finalResultBuffers ||= concatUint8(resultBuffers); + const full = finalResultBuffers; + response = full.buffer; // ArrayBuffer + break; + } + case "blob": { + finalResultBuffers ||= concatUint8(resultBuffers); + const full = finalResultBuffers; + const type = contentType || "application/octet-stream"; + response = new Blob([full], { type }); // Blob + break; + } + default: { + // text + response = `${this.getResponseText()}`; + break; + } + } + if (reqDone) { + resultTexts.length = 0; + resultBuffers.length = 0; + } + } + if (responseTypeOriginal === "json" && response === null) { + response = undefined; // TM不使用null,使用undefined + } + return response as string | GMXhrResponseObjectType | null | undefined; + }, + getResponseXML() { + if (responseXML === false) { + // 注: isStreamResponse 为 true 时 responseXML 不会为 false + const text = this.getResponseText(); + const mime = getMimeType(contentType); + const parseType = docParseTypes.has(mime) ? (mime as DOMParserSupportedType) : "text/xml"; + if (text) { + try { + responseXML = new DOMParser().parseFromString(text, parseType); + } catch (e) { + // 对齐 TM 处理。Trusted Type Policy受限制时返回 null + responseXML = null; + console.error(e); + } + } + } + return responseXML as Document | null | undefined; + }, + getResponseText() { + if (responseText === false) { + // 注: isStreamResponse 为 true 时 responseText 不会为 false + if (resultType === ChunkResponseCode.UINT8_ARRAY_BUFFER) { + finalResultBuffers ||= concatUint8(resultBuffers); + const buf = finalResultBuffers.buffer as ArrayBuffer; + const decoder = new TextDecoder("utf-8"); + const text = decoder.decode(buf); + responseText = text; + } else { + // resultType === ChunkResponseCode.STRING + if (finalResultText === null) finalResultText = `${resultTexts.join("")}`; + responseText = finalResultText; + } + if (reqDone) { + resultTexts.length = 0; + resultBuffers.length = 0; + } + } + return responseText as string | undefined; + }, + }; + }; + + const makeResponseRet = (retParam: GMXHRResponseType, addGetters: boolean, contentType: string) => { + let descriptors: ReturnType> = { + ...Object.getOwnPropertyDescriptors(retParam), + }; + if (!addGetters) return Object.create(null, descriptors); + descriptors = { + ...descriptors, + ...xhrResponseGetters, + }; + // 对齐 TM, res.constructor = undefined, res.__proto__ = undefined + const retParamObject: GMXHRResponseType = Object.create(null, descriptors); + // 外部没引用 retParamObject 时,retTemp 会被自动GC + const retTemp = makeRetTemp(contentType); + retStateFnMap.set(retParamObject, retTemp); + return retParamObject; + }; + const makeXHRCallbackParam_ = ( res: { // @@ -373,12 +484,8 @@ export function GM_xmlhttpRequest( if (allowResponse) { // 依照 TM 的规则:当 readyState 不等于 4 时,回应中不会有 response、responseXML 或 responseText。 addGetters = true; - retParam = { - ...retParamBase, - }; - } else { - retParam = retParamBase; } + retParam = retParamBase; if (res.error) { retParam.error = res.error; } @@ -387,112 +494,7 @@ export function GM_xmlhttpRequest( retParam.context = contentContext; } - let descriptors: ReturnType> = { - ...Object.getOwnPropertyDescriptors(retParam), - }; - let retTemp: RetStateFnRecord | null = null; - if (addGetters) { - // 外部没引用 retParamObject 时,retTemp 会被自动GC - retTemp = { - getResponse() { - if (response === false) { - // 注: isStreamResponse 为 true 时 response 不会为 false - switch (responseTypeOriginal) { - case "json": { - const text = this.getResponseText(); - let o = undefined; - if (text) { - try { - o = Native.jsonParse(text); - } catch { - // ignored - } - } - response = o; // TM兼容 -> o : object | undefined - break; - } - case "document": { - response = this.getResponseXML(); - break; - } - case "arraybuffer": { - finalResultBuffers ||= concatUint8(resultBuffers); - const full = finalResultBuffers; - response = full.buffer; // ArrayBuffer - break; - } - case "blob": { - finalResultBuffers ||= concatUint8(resultBuffers); - const full = finalResultBuffers; - const type = res.contentType || "application/octet-stream"; - response = new Blob([full], { type }); // Blob - break; - } - default: { - // text - response = `${this.getResponseText()}`; - break; - } - } - if (reqDone) { - resultTexts.length = 0; - resultBuffers.length = 0; - } - } - if (responseTypeOriginal === "json" && response === null) { - response = undefined; // TM不使用null,使用undefined - } - return response as string | GMXhrResponseObjectType | null | undefined; - }, - getResponseXML() { - if (responseXML === false) { - // 注: isStreamResponse 为 true 时 responseXML 不会为 false - const text = this.getResponseText(); - const mime = getMimeType(res.contentType); - const parseType = docParseTypes.has(mime) ? (mime as DOMParserSupportedType) : "text/xml"; - if (text) { - try { - responseXML = new DOMParser().parseFromString(text, parseType); - } catch (e) { - // 对齐 TM 处理。Trusted Type Policy受限制时返回 null - responseXML = null; - console.error(e); - } - } - } - return responseXML as Document | null | undefined; - }, - getResponseText() { - if (responseText === false) { - // 注: isStreamResponse 为 true 时 responseText 不会为 false - if (resultType === ChunkResponseCode.UINT8_ARRAY_BUFFER) { - finalResultBuffers ||= concatUint8(resultBuffers); - const buf = finalResultBuffers.buffer as ArrayBuffer; - const decoder = new TextDecoder("utf-8"); - const text = decoder.decode(buf); - responseText = text; - } else { - // resultType === ChunkResponseCode.STRING - if (finalResultText === null) finalResultText = `${resultTexts.join("")}`; - responseText = finalResultText; - } - if (reqDone) { - resultTexts.length = 0; - resultBuffers.length = 0; - } - } - return responseText as string | undefined; - }, - }; - descriptors = { - ...descriptors, - ...xhrResponseGetters, - }; - } - // 对齐 TM, res.constructor = undefined, res.__proto__ = undefined - const retParamObject: GMXHRResponseType = Object.create(null, descriptors); - if (retTemp) retStateFnMap.set(retParamObject, retTemp); - return retParamObject; + return makeResponseRet(retParam, addGetters, res.contentType); }; let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; doAbort = (data: any) => { From 7cf9ef4e736e2dc1b957e8a6de902126348276af Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:32:40 +0900 Subject: [PATCH 6/9] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20falsy=20text=20?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 10651a6b6..f567716c5 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -361,7 +361,7 @@ export function GM_xmlhttpRequest( const text = this.getResponseText(); const mime = getMimeType(contentType); const parseType = docParseTypes.has(mime) ? (mime as DOMParserSupportedType) : "text/xml"; - if (text) { + if (text !== undefined) { try { responseXML = new DOMParser().parseFromString(text, parseType); } catch (e) { @@ -369,6 +369,8 @@ export function GM_xmlhttpRequest( responseXML = null; console.error(e); } + } else { + responseXML = undefined; } } return responseXML as Document | null | undefined; From d3bf10b244f86dfaab8f1146afa1b75bd6de4315 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:08:55 +0900 Subject: [PATCH 7/9] =?UTF-8?q?Native=E5=8C=96=20Object.create=20=E5=92=8C?= =?UTF-8?q?=20Object.getOwnPropertyDescriptors=20=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=8F=97=E5=88=B0=E4=BB=A3=E7=A0=81=E9=AA=91=E5=8A=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 2 ++ src/app/service/content/gm_api/gm_xhr.ts | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index e932f8c3c..33728ed86 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -7,6 +7,8 @@ export const Native = { structuredClone: typeof structuredClone === "function" ? structuredClone : unsupportedAPI, jsonStringify: JSON.stringify.bind(JSON), jsonParse: JSON.parse.bind(JSON), + objectCreate: Object.create.bind(Object), + objectGetOwnPropertyDescriptors: Object.getOwnPropertyDescriptors.bind(Object), } as const; export const customClone = (o: any) => { diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index f567716c5..497ac3dd2 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -401,15 +401,15 @@ export function GM_xmlhttpRequest( const makeResponseRet = (retParam: GMXHRResponseType, addGetters: boolean, contentType: string) => { let descriptors: ReturnType> = { - ...Object.getOwnPropertyDescriptors(retParam), + ...Native.objectGetOwnPropertyDescriptors(retParam), }; - if (!addGetters) return Object.create(null, descriptors); + if (!addGetters) return Native.objectCreate(null, descriptors); descriptors = { ...descriptors, ...xhrResponseGetters, }; // 对齐 TM, res.constructor = undefined, res.__proto__ = undefined - const retParamObject: GMXHRResponseType = Object.create(null, descriptors); + const retParamObject: GMXHRResponseType = Native.objectCreate(null, descriptors); // 外部没引用 retParamObject 时,retTemp 会被自动GC const retTemp = makeRetTemp(contentType); retStateFnMap.set(retParamObject, retTemp); From f62efe95d967cc3cfaedee52890522a4a243f320 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:18:40 +0900 Subject: [PATCH 8/9] Update gm_xhr_test.js --- example/tests/gm_xhr_test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js index 31e07b0bb..2a03deead 100644 --- a/example/tests/gm_xhr_test.js +++ b/example/tests/gm_xhr_test.js @@ -1,7 +1,7 @@ // ==UserScript== // @name GM_xmlhttpRequest Exhaustive Test Harness v3 // @namespace tm-gmxhr-test -// @version 1.2.2 +// @version 1.2.3 // @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 @@ -120,7 +120,7 @@ const enableTool = true; gap: "8px", }, }, - h("div", { style: { fontWeight: "600" } }, "GM_xmlhttpRequest Test Harness"), + 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("button", { id: "start", style: btn() }, "Run"), h("button", { id: "clear", style: btn() }, "Clear") @@ -133,7 +133,7 @@ const enableTool = true; ), h( "details", - { id: "queueWrap", open: true, style: { padding: "0 12px 6px", borderBottom: "1px solid #222" } }, + { id: "queueWrap", open: false, style: { padding: "0 12px 6px", borderBottom: "1px solid #222" } }, h("summary", {}, "Pending tests"), h( "div", From d1f4b2a1005f4eaf1d4461162656492ef5f7bc8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 16 Feb 2026 16:27:44 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E5=A4=84=E7=90=86=E6=8B=BC=E5=86=99?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 497ac3dd2..34bbf52dd 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -614,10 +614,10 @@ export function GM_xmlhttpRequest( responseText = false; finalResultBuffers = null; finalResultText = null; - const xhrReponse = makeXHRCallbackParam?.(data) ?? {}; - details.onloadend?.(xhrReponse); + const xhrResponse = makeXHRCallbackParam?.(data) ?? {}; + details.onloadend?.(xhrResponse); if (errorOccur === null) { - retPromiseResolve?.(xhrReponse); + retPromiseResolve?.(xhrResponse); } else { retPromiseReject?.(errorOccur); }