Skip to content

✨ 为 GM_xmlhttpRequest / GM.xmlHttpRequest 支持上传进度事件 (GM/TM/VM) - #1566

Draft
cyfung1031 wants to merge 21 commits into
mainfrom
agent/gm-xhr-upload-events
Draft

cyfung1031 wants to merge 21 commits into
mainfrom
agent/gm-xhr-upload-events

Conversation

@cyfung1031

@cyfung1031 cyfung1031 commented Jul 11, 2026 •

Copy link
Copy Markdown
Collaborator

Checklist / 检查清单

  • Fixes API Compatibility Issue (GM/TM/VM)
  • Code reviewed by human / 代码通过人工检查
  • Changes tested / 已完成测试

背景

当前 GM_xmlhttpRequest / GM.xmlHttpRequest 只能接收主请求下载阶段的事件,无法通过 details.upload 获取上传阶段的开始、进度、完成或异常信息。

这会影响文件上传、表单上传等需要显示上传进度、处理超时或响应用户中止操作的用户脚本,也使 ScriptCat 与原生 XMLHttpRequestUpload 以及常见用户脚本管理器的接口存在差异。

本次改动

  • 为 details.upload 增加以下回调支持:

    • onloadstart
    • onprogress
    • onload
    • onloadend
    • onerror
    • onabort
    • ontimeout
  • 后台原生 XMLHttpRequest 路径监听 xhr.upload,通过现有消息通道转发为 onupload* 事件。

  • 内容脚本侧将 onupload* 消息分发到 details.upload.on*,并统一提供:

    • loaded
    • total
    • lengthComputable
    • done
    • totalSize
  • 请求体编码完成后计算 hasUpload,只在请求确实可能产生上传生命周期时通知后台绑定上传监听器。

  • 对齐原生 XHR 的主动中止行为,处理上传回调与主请求回调的顺序、去重和同步竞态。

  • 更新 GMSend.XHRDetails、GMTypes.XHRDetails,新增 GMTypes.XHRUpload,并同步模板、英文及简体中文声明文件。

  • 新增后台、内容脚本、运行时及浏览器集成测试,覆盖正常上传、异常、超时、主动中止、事件顺序和竞态。

  • 调整 GM API E2E fixture:每个 Playwright worker 只执行一次 user scripts 权限初始化,各测试从预配置 profile 创建独立副本,减少并行 CI 中重复启动 Chrome 导致的 Service Worker 超时,同时保持测试状态隔离。

实现考虑

1. 只在确实需要时绑定 xhr.upload

为 XMLHttpRequestUpload 注册监听器会改变跨域请求行为,并可能触发额外的 CORS 预检请求。

因此不能无条件为所有 XHR 请求绑定上传监听器。

内容侧会在请求体完成编码后计算 canHaveUploadLifecycle,仅在同时满足以下条件时向后台发送 hasUpload: true:

  1. 请求不会切换到 fetch 传输;
  2. 方法不是 GET 或 HEAD;
  3. 存在实际请求体;
  4. details.upload 中至少有一个回调值确实是函数。

后台仅在原生 XHR 路径且 details.hasUpload === true 时绑定七类 xhr.upload 事件。

这样不会让未使用上传回调的既有脚本承担额外预检、性能开销和跨域兼容风险。

2. 区分“注册了回调”和“真实存在上传生命周期”

仅传入 details.upload 并不代表浏览器一定会产生上传事件:

  • GET / HEAD 的请求体会被忽略;
  • 没有实际请求体时,上传阶段在发送前就视为完成;
  • fetch 传输路径没有 XMLHttpRequestUpload;
  • details.upload 中 truthy 但非函数的值不是有效回调。

因此后台监听开关、本地完成状态以及 abort() 的上传事件补发都统一依赖 canHaveUploadLifecycle。

这可以避免在根本不存在上传阶段的请求中错误触发 upload.onabort 或 upload.onloadend。

3. 对齐原生 XHR 的中止语义和事件顺序

当上传尚未完成时主动调用返回对象的 abort():

  1. 先触发 upload.onabort;
  2. 再触发 upload.onloadend;
  3. 然后触发主请求 onabort;
  4. 最后触发主请求 onloadend。

实现使用 uploadDone 和 uploadLoadEndCalled 维护上传完成状态并保证 upload.onloadend 只执行一次。

同时处理以下竞态:

  • 在 upload.onload 回调中同步调用 abort() 时,上传已被视为完成,不应误触发 upload.onabort;
  • 消息通道断开可能导致后台已发出或即将发出的真实 upload.onloadend 丢失,因此需要本地兜底;
  • 真实 upload.onloadend 和本地兜底只能执行一次;
  • 上传正常完成后再中止主请求时,兜底 upload.onloadend 会保留最后一次真实上传进度;
  • 上传以 error、abort 或 timeout 结束后,返回对象的 abort() 会成为空操作,让真实的主请求终止事件继续到达,避免被本地合成的 AbortError 覆盖。

4. 上传事件统一使用进度响应结构

主请求 onprogress 与全部上传事件共用同一套进度参数构造逻辑。

上传事件会尽量保留现有 XHR 响应字段,并统一补充:

  • lengthComputable
  • loaded
  • total
  • done
  • totalSize

其中 done 与 loaded 一致,totalSize 与 total 一致,用于兼容现有 ScriptCat 进度回调结构。

5. 只接受函数类型的上传回调

details.upload 中 truthy 但非函数的值不会:

  • 启用后台上传监听器;
  • 触发额外的 CORS 预检;
  • 在事件到达时被尝试调用。

这样既避免无效配置改变请求行为,也避免运行时出现非函数调用异常。

6. 降低 E2E 并行执行的启动开销

GM API 浏览器测试需要先通过 chrome://extensions 和 developerPrivate 启用 user scripts 权限,再重新启动持久化浏览器上下文。

此前每个测试都会重复执行完整初始化;并行 worker 较多时,大量 Chrome 实例会同时争用 CPU,使扩展 Service Worker 启动时间超过测试超时。

现在权限初始化改为 worker 级 fixture:

  1. 每个 worker 创建并初始化一次基础 profile;
  2. 每个测试复制该 profile 到独立临时目录;
  3. 测试结束后删除副本;
  4. worker 结束后删除基础 profile。

这样可以减少重复启动成本,同时避免脚本、存储和扩展状态在不同测试之间泄漏。

已知限制

1. 强制走 fetch 的配置不支持上传回调

当前以下配置会让 ScriptCat 使用 fetch 传输,因此不会产生 details.upload 事件:

  • fetch: true
  • 设置 redirect
  • anonymous: true 或 mozAnon: true
  • responseType: "stream"

其中 anonymous / mozAnon 切换到 fetch 是现有架构通过 credentials: "omit" 实现不携带 Cookie 的方式。

要让匿名请求继续使用原生 XHR,需要重新设计跨域 Cookie 处理机制,影响范围超出本 PR,因此本次只明确记录限制,不调整现有传输架构。

2. 兼容目标

本实现以原生 XMLHttpRequestUpload 的事件模型和顺序为准,并提供与常见用户脚本管理器相近的 details.upload 接口。

类型声明和说明不承诺与某个特定版本的 Tampermonkey、Greasemonkey 或 Violentmonkey 在所有边缘行为上完全一致。

3. 进度总量由浏览器决定

所有上传回调都会提供 loaded、total 和 lengthComputable,但浏览器不保证每次都能计算上传总大小。

用户脚本仍应在 lengthComputable === false 时使用不确定进度展示,而不能假设 total 始终有效。

建议审查重点

  • hasUpload / canHaveUploadLifecycle 是否完整覆盖所有传输选择条件;
  • 是否只有函数类型的上传回调会改变后台监听和跨域预检行为;
  • 主请求与上传事件之间的完成状态、去重和竞态处理;
  • upload.onload 内同步调用 abort() 时的事件顺序;
  • 上传异常后再次调用 abort() 是否会保留真实的主请求错误类型;
  • 强制 fetch 配置下的限制说明是否足够清晰;
  • 新增公开类型是否符合项目现有 API 命名和兼容策略;
  • worker 级 E2E profile 是否同时保证 CI 稳定性和测试状态隔离。

参考

https://wiki.greasespot.net/GM.xmlHttpRequest

Tampermonkey/tampermonkey#1429

violentmonkey/violentmonkey#2302

cyfung1031 and others added 5 commits July 11, 2026 17:14
对齐 Tampermonkey:details.upload 可挂载 onloadstart/onprogress/onload/
onloadend/onerror/onabort/ontimeout,对应原生 XMLHttpRequestUpload 事件。
仅原生 XHR 路径支持(fetch 模式无法获取上传进度,维持既有限制)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- 仅在脚本实际注册了 upload 回调时才为 xhr.upload 绑定监听器(GMSend.XHRDetails
  新增 hasUpload 标记):为 xhr.upload 注册任何监听器都会令浏览器对跨域请求强制
  触发 CORS 预检 (OPTIONS),未使用该功能的脚本不应承担这一开销/兼容性风险。
- 调用返回的 abort() 时,若 upload 阶段尚未完成,补发 details.upload.onabort /
  onloadend(对齐原生 XMLHttpRequest.abort() 语义);upload 阶段已完成后再次
  abort 不会重复触发。放宽 abort() 的触发条件,使仅注册了 upload 回调(未注册
  顶层 onabort)的脚本也能正常收到 abort 通知。
- 统一 upload 各回调 (onloadstart/onload/onloadend/onerror/onabort/ontimeout)
  均携带 loaded/total/lengthComputable(对齐 XHR 规范:upload 的事件均为
  ProgressEvent),类型定义同步放宽为 Listener<XHRProgress>。
- 更新/新增测试覆盖以上场景。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- uploadDone 现在于 load/error/abort/timeout 到达时即置位(对齐规范:upload
  complete flag 在这些事件派发前已置位),而不仅仅是 loadend;避免在
  upload.onload 回调内同步调用 abort() 时被误判为"尚未完成"而重复触发
  upload.onabort。
- 新增 uploadLoadEndCalled 去重:upload.onloadend 保证恰好触发一次,无论是
  由真实的 onuploadloadend 消息触发,还是由 abort() 兜底补发触发(两者可能
  竞态:真实消息可能已在通道断开前送出但因断线而无法再被处理)。
- abort() 补发的 upload.onabort / onloadend 现在统一经由
  makeUploadCallbackParam 携带 loaded:0/total:0/lengthComputable:false,
  不再是缺少进度字段的裸响应对象,符合已声明的 XHRProgress 类型契约。
- hasAnyUploadHandler 改为只接受函数值:truthy 但非函数的回调(如
  { onprogress: true })不再被视为已注册,避免既误触发额外 CORS 预检,
  又在事件到达时把非函数值当函数调用而抛出。
- 新增测试覆盖:upload.onload 内同步 abort、补发数据的进度字段、
  abort 时的事件顺序 (upload.abort → upload.loadend → 主 abort → 主 loadend)、
  以及非函数 upload 回调不启用 upload 监听。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- 对齐规范:request error steps 在派发 upload 的 error/abort/timeout 前,
  已将整个请求置为 DONE(原生此时再调用 abort() 不会产生新事件)。新增
  suppressSyntheticAbort 标记,在 onuploaderror/onuploadabort/onuploadtimeout
  到达时置位;置位后返回的 abort() 变为空操作(不再断开通道、不再合成
  AbortError),让真实的主 onerror/ontimeout/onabort 消息能够正常到达并
  驱动正确的回调,而不是被本地合成的 onabort 抢先覆盖。
- 新增 lastUploadEventData,记录 upload 成功完成(onuploadload)时的真实
  loaded/total/lengthComputable;在 upload.onload 回调内同步调用 abort()
  导致真实 onuploadloadend 消息因通道断开而丢失时,兜底补发的 onloadend
  使用这份真实数据,而不是 abort() 合成响应对象里缺省的 undefined。
- 新增测试覆盖:upload.onerror / upload.ontimeout 内调用 abort() 不应覆盖
  真实的主 onerror/ontimeout;upload.onload 内 abort() 导致 loadend 消息
  丢失时,兜底补发的 onloadend 应携带真实的已传输字节数。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- 对齐 XHR 规范:GET/HEAD 请求体会被忽略;无请求体时 upload complete flag
  在发送前已置位;fetch 传输不绑定 xhr.upload——以上情况均不会产生真实的
  原生 upload 阶段。此前 uploadDone 仅依据"是否注册了 upload 回调"初始化,
  与是否存在真实 upload 阶段无关,导致这些场景下调用 abort() 仍会错误地
  合成 upload.onabort / onloadend。
- 新增 canHaveUploadLifecycle:在请求体编码完成、确认 method/body/transport
  后计算(method 非 GET/HEAD、有实际请求体、且不会走 fetch 传输、且注册了
  回调),用其替代 hasAnyUploadHandler(details.upload) 来初始化 uploadDone,
  并据此决定发往后台的 hasUpload 标记——避免此前对 GET/无请求体的原生 XHR
  请求也绑定 xhr.upload 监听器(本可避免的 CORS 预检开销)。
- doAbort 内的 upload 合成逻辑整体收敛到 canHaveUploadLifecycle 判断之下,
  避免"upload 从未开始"与"upload 已成功完成"两种 uploadDone=true 的语义
  被混淆(前者不应触发任何 upload.* 回调,后者才应携带真实数据补发 loadend)。
- 更新/新增测试:GET、HEAD、POST 无 data、fetch/redirect/anonymous/stream
  等场景下 hasUpload 应为 false 且 abort() 不触发 upload 事件;POST 带
  请求体的既有测试同步补上 method/data 以符合真实场景。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031 cyfung1031 changed the title ✨ 为 GM_xmlhttpRequest / GM.xmlHttpRequest 支持上传进度事件 ✨ 为 GM_xmlhttpRequest / GM.xmlHttpRequest 支持上传进度事件 (GM/TM/VM) Jul 11, 2026
cyfung1031 and others added 4 commits July 11, 2026 23:11
CI 下 workers:2 并行启动多个 Chrome 实例造成资源争抢,30s 偶发不足以等到扩展
service worker 就绪,导致 fixture 阶段超时误报(与 gm_xhr.ts 上传事件改动无关,
该测试不涉及任何 GM_xmlhttpRequest 调用)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
之前每个 test 都要完整走两次 launchPersistentContext(含 chrome://extensions
导航 + developerPrivate 权限调用),CI 下多个 worker 并行时大量并发 Chrome
启动互相抢占 CPU,是 service worker 启动超时误报的根因。

参照 e2e/agent-fixtures.ts 已验证过的模式:新增 worker 级 gmApiProfileDir
fixture,只在每个 worker 里做一次 Phase 1 权限配置,每个 test 改为拷贝该
profile 目录后只做一次轻量 Phase 2 启动。本地验证:整个 spec 文件 7 个测试
2 workers 下 16.8s 全部通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031 cyfung1031 added the GM API 支持一下TM/VM/FM的API label Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

按 PickInvariant 的 DELTA_AUDIT,对 base 3fc6ced2a16ca6abfbc7bfefaa66d1cc97343e34 → head ce39a4cb870cf457673f0e1318c3681e76209d3c 审查了上传生命周期启用条件、content→background 参数序列化、native XHR/fetch 分流、事件分发/abort、类型声明与测试。

结论:正常 native XHR 上传事件、字段映射、声明和去重逻辑基本覆盖,但以下跨层接缝仍有缺口:

  • [P1,确认] mozAnon 参与了 content 侧 fetch 判定,却没有传到 background。 src/app/service/content/gm_api/gm_xhr.ts:150 用 details.anonymous ?? details.mozAnon 禁用上传生命周期,但 :234-247 序列化参数没有 mozAnon。因此 mozAnon: true 且未设置 anonymous 时,background src/pkg/utils/xhr/bg_gm_xhr.ts:254-264 看不到该值,可能选择 native XHR,并在 :449 设置 withCredentials = true;content 与 background 对匿名传输的判断相反,匿名请求可能继续带凭据。建议传递该字段,或统一由一个已序列化的 transport decision 驱动两侧。

  • [P2,确认] truthy 非函数的 upload handler 会在事件到达时抛错。 src/app/service/content/gm_api/gm_xhr.ts:136-146 的启用判断只接受函数,但 :579 和 :805 使用 optional-call;例如 upload: { onprogress: true } 会启用其他函数 handler 后,在进度/收尾分发中尝试调用布尔值。结果是上传事件被丢弃,且异常可能打断 abort/loadend 收尾。建议分发时也用 typeof handler === "function" 保护,并补非函数值测试。

  • [P2,确认/既有 body 接缝被本 PR 的 gate 暴露] hasUpload 可能与实际发送的 body 不一致。 src/app/service/content/gm_api/gm_xhr.ts:268-275 对 POST 的 object 编码结果判定存在请求体并设置 hasUpload: true;但无 Content-Type 时,background src/pkg/utils/xhr/bg_gm_xhr.ts:497-516 会把该 object 路径清成 null 后发送。此时 :401 仍绑定 xhr.upload,可能承担不必要的 CORS 预检,却没有真实上传生命周期。建议让 hasUpload 基于最终发送 body,或统一 body 编码与判定。

已检查:函数回调启用谓词、native XHR 监听绑定、事件名/进度字段、upload/main abort 顺序、loadend 去重、模板/英文/中文声明。未验证:真实浏览器的 CORS 预检、native upload error/timeout/loadend 时序及 E2E;PR 中的 worker profile 初始化调整不在本上传语义结论内。

Copy link
Copy Markdown
Collaborator Author

已保留原 PR head ce39a4cb870cf457673f0e1318c3681e76209d3c 作为对比基准,并先同步到最新 main。在原 PR 源分支上追加了两个 correction commits,PR 标题和正文未改:

  • 7fc214bf 🐛 修正 GM XHR 上传生命周期接缝

    • 完整传递 mozAnon,使 content 与后台的匿名传输判定一致。
    • 让 hasUpload 与后台对普通 object body 的实际发送语义一致。
    • 所有 upload handler 调用统一检查函数类型。
    • 远端消息连接在终态消息前断开时,补发 upload/main 终态并结算 Promise,同时避免主动 abort 重复收尾。
  • 4facba0b 🐛 修正 GM XHR 二进制请求体传输

    • 让 DataView 和其他 typed array 作为原生 XMLHttpRequest body 发送,修复编码端与后台 body 白名单不一致。

本地验证已通过:

  • GM XHR 内容/后台定向测试:47/47
  • 运行时 GM API 测试:43/43
  • pnpm run typecheck
  • Prettier 检查
  • ESLint 检查
  • pnpm run build(仅现有 bundle size / Monaco critical dependency 警告)

对比原 PR:原 PR 仍为 10 个 changed files;本次 correction 只改动其中 4 个文件。未执行真实浏览器跨域 CORS/cookie 行为和 Playwright E2E 验证。当前 GitHub 的 License Compliance check 为 pending,不能视为通过。

当前发布 head:4facba0b。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

GM API 支持一下TM/VM/FM的API

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant