Skip to content

[Feature] ScriptCat 应能注入 <script type="module">#1487

Draft
cyfung1031 wants to merge 6 commits into
mainfrom
feat/script-module
Draft

[Feature] ScriptCat 应能注入 <script type="module">#1487
cyfung1031 wants to merge 6 commits into
mainfrom
feat/script-module

Conversation

@cyfung1031

@cyfung1031 cyfung1031 commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

Checklist / 检查清单

  • Implements new feature: @script-module ES Module injection (GM/TM/VM parity)
  • Code reviewed by human / 代码通过人工检查
  • Changes tested / 已完成测试

背景

用户脚本目前始终以 classic script 方式注入,无法使用静态 import/export(例如直接 import { chunk } from "https://cdn.jsdelivr.net/npm/lodash-es/+esm"),只能退回 @require 或手动 fetch + eval 等变通方式。

原生 ES Module(<script type="module">)支持静态 import/export、顶层 await、独立的模块作用域(顶层 thisundefined,顶层声明不挂载到 window),但 chrome.userScripts API 目前无法直接以 type="module" 注册用户脚本。

参考:

Close #1486

本次改动

  • 新增 @script-module metadata:脚本声明后,编译阶段改为生成一段会在页面 MAIN world 内动态创建 <script type="module"> 的注入代码(wrapScriptModuleCode()),而不是直接把脚本代码当作 classic script 执行。
  • module 通过临时的 document.<随机属性> 转交拿到 GM(按脚本 @grant 实际授予的方法)、windowunsafeWindowtopparentglobalThis,读取后立即删除该临时属性。不提供旧式 GM_* 全局或 GM_info/CAT_*——ESM 模式下按设计只暴露 GM 命名空间对象。
  • isScriptModule() 判定规则(@script-module 为空值或 "true" 时生效):
    • @inject-into content(USER_SCRIPT 隔离世界)互斥,二者同时声明时 @script-module 不生效。
    • @require 互斥:@require 的内容在外层 classic script 作用域执行,module 无法访问那些词法变量,因此同时声明时 @script-module 不生效,回退普通编译路径。
    • @unwrap@early-start 可以组合:@unwrap 优先于 @script-module(有 @unwrap 时走 scriptlet 编译路径,module 包装不生效);@early-start 下 module 包装仍然生效,转交的 GM 与外层沙盒共享同一对象引用,事后补齐 isIncognito/sandboxMode/userAgentData 时 module 侧能同步看到。
  • restoreJSCodeFromCompiledResource()(扩展重启或重新注册脚本后的缓存恢复路径)现在与首次编译路径(compileInjectionCode())共用同一个 wrapScriptModuleCode(),保证两条路径生成一致的注入代码。
  • 为动态创建的 module 元素加上 error 事件监听,在 import 解析失败或被页面 CSP 拦截时输出可定位到脚本名的 console.error,而非静默失败。
  • 新增 vitest 单元测试(isScriptModulewrapScriptModuleCodecompileInjectionCoderestoreJSCodeFromCompiledResource 的各组合分支)与 e2e 测试(example/tests/script_module_e2e_test.js + e2e/gm-api.spec.ts),在真实浏览器中验证:module 顶层 thisundefineddocument.currentScriptnull、顶层声明不挂载到 windowwindow/unsafeWindow 与既有沙盒脚本语义一致、静态 import 真实可用(区别于语法特征匹配)、GM.setValue/GM.getValue 正常工作。

实现考虑

1. 为什么用临时 document 属性转交 GM/window 等对象

<script type="module"> 是独立解析、独立作用域的顶层脚本,无法像普通函数闭包那样直接共享外层 classic script 的变量。要把已经按 @grant 构建好的 GM 对象(以及 window/unsafeWindow 等沙盒变量)交给它,只能通过两者都能访问的共享对象。

当前实现选择在 MAIN world 的 document 上写入一个随机命名的临时属性,由 module 在自己的第一行代码里读取后立即 delete。这个方案的时间窗口很短(属性写入到 module 读取之间只隔一次 appendChild + 浏览器调度 module 执行),但由于 <script type="module"> 的执行本身是异步的(不像普通内联 <script>),如果页面提前 hook 了 document.createElement/appendChild 或主动枚举 document 的新增属性,理论上存在在属性被删除前读取到它的窗口。

已评估的更强隔离方案:改为用 import() 动态加载一个 Blob URL 模块,把 GM 等对象作为普通函数参数传给模块导出的函数,从而完全不经过任何页面可见对象。但这要求把脚本代码包在一个函数里,会牺牲用户脚本顶层 import 语句的自由摆放位置以及真正的顶层 await 语义。本次维持现有 document 属性转交方案,把这个取舍留给后续按需评估。

2. @require@script-module 的互斥

@require 引入的代码在外层 classic script 的词法作用域里执行,@script-module 生成的 module 是完全独立的作用域,无法访问外层的 @require 变量。与其让两者组合后静默产生"require 的内容在 module 里访问不到"的错误行为,isScriptModule() 在检测到 @require 时直接判定 @script-module 不生效,回退到普通编译路径(@require 正常工作,只是不再支持静态 import)。

3. 缓存恢复路径与首次编译路径的一致性

compileInjectionCode()(首次编译/重新构建)与 restoreJSCodeFromCompiledResource()(扩展重启或重新注册脚本后,从 CompiledResourceDAO 缓存恢复)此前是两套独立实现,后者遗漏了 module 包装逻辑,导致缓存恢复后 @script-module 脚本会静默退化为 classic script(import/export 报语法错误)。现在两条路径统一调用 wrapScriptModuleCode(),避免因为走了哪条路径而产生行为差异。

已知限制

  1. GM 等对象通过临时 document 属性转交,理论上存在页面主动 hook DOM API 提前读取的时间窗口(见"实现考虑"第 1 点);已知晓,本次未改为更强隔离的方案。
  2. @require@script-module 不能同时生效(详见上文)。
  3. @inject-into content(USER_SCRIPT 隔离世界)与 @script-module 不能同时生效,@script-module 目前只支持默认的 MAIN world 注入。
  4. inline <script type="module"> 会受页面自身 CSP(script-src)限制;拦截时会有 console.error 提示,但不提供绕过手段,也不在扩展 UI 里做额外的诊断展示。
  5. 相对 import、bare specifier、import map、CORS、<base> 的解析规则完全由浏览器按当前页面上下文处理,ScriptCat 不做任何改写或特殊解析。
  6. module 内只暴露 GM(按 @grant 授予的方法),不提供旧式 GM_* 全局、GM_infoCAT_*;需要这些的脚本请勿使用 @script-module
  7. 编辑器(Monaco + ESLint)的 sourceType 仍固定为 "script",未按 @script-module 动态切换为 "module",模块顶层 import/export 语法在编辑器里可能被误报为语法错误(不影响实际运行)。
  8. 尚未针对 Chromium 与 Firefox 136+ 在 chrome.userScripts world/realm 行为上的潜在差异做浏览器矩阵验证。

建议审查重点

  • isScriptModule() 的判定条件(@inject-into content 互斥、@require 互斥、@unwrap/@early-start 组合行为)是否符合预期;
  • wrapScriptModuleCode() 生成的临时 document 属性转交时序,以及"实现考虑"第 1 点描述的残余风险是否可以接受;
  • restoreJSCodeFromCompiledResource()compileInjectionCode() 在各种 metadata 组合下是否始终生成一致的注入代码;
  • module 注入失败(CSP 拦截、import 解析失败)时的 console.error 提示是否清晰可定位到具体脚本;
  • @grant 授予的 GM 方法在 module 内的行为是否与外层沙盒脚本一致。

验证

  • pnpm exec vitest run --no-coverage:3382 个测试全部通过。
  • pnpm exec tsc --noEmit:无类型错误。
  • pnpm exec eslint .:无报错(example/tests/*.js 按项目现有配置被 lint 忽略,属预期)。
  • pnpm exec prettier --check:格式检查通过。
  • e2e(Playwright,e2e/gm-api.spec.ts@script-module tests 用例 + example/tests/script_module_e2e_test.js)此前已在本地全部通过(见下方评论历史),本轮未重新执行浏览器测试。

@CodFrm

This comment was marked as outdated.

@cyfung1031

This comment was marked as outdated.

@cyfung1031
cyfung1031 force-pushed the feat/script-module branch from a873957 to 25ba263 Compare July 17, 2026 12:08
@cyfung1031
cyfung1031 changed the base branch from release/v1.4 to main July 17, 2026 12:08
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

这个可以之后再处理

单元测试覆盖 isScriptModule 判断逻辑(含与 @inject-into content 互斥)
及 compileInjectionCode 对 script-module 脚本的编译路径(含 early-start
组合场景);新增 e2e 用例通过实际浏览器验证注入的确是真正的
<script type="module">(顶层 this 为 undefined、document.currentScript
为 null、顶层声明不挂载到 window),且 GM.* API 与既有沙盒 window/
unsafeWindow 语义保持一致。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031

This comment was marked as outdated.

新增 mock server 路由 /module-lib.js 提供一个真实的可 export 模块;
script_module_e2e_test.js 在脚本顶层用静态 `import { addNumbers,
MODULE_LIB_MARKER } from "/module-lib.js"` 引入并调用/校验其值。
普通(非 module)<script> 遇到 import 语句会直接抛出 SyntaxError 导致
整份脚本无法执行,因此该用例能确认注入的确是可被浏览器当作 ES module
解析执行的 <script type="module">,而不只是语法特征匹配。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031

This comment was marked as outdated.

cyfung1031 and others added 2 commits July 17, 2026 22:07
review 指出 @script-module 曾把 GM/window/unsafeWindow 等经由页面共享的
document.<随机属性> 传给注入的 <script type="module">,页面可 hook
document.createElement/appendChild 在临时属性被删除前窃取具备权限的 GM
对象。在浏览器能力支持 module 复用沙盒 Proxy 之前,改为:

- isScriptModule() 在代码层面强制要求 @grant none,并排除 @require(module
  无法访问外层 classic-script 的词法变量)与 early-start(GM_info 的
  isIncognito/sandboxMode/userAgentData 可能尚未补齐,且无法像沙盒 Proxy
  那样以同一对象引用事后回填),而不仅在文档中说明限制。
- wrapScriptModuleCode() 不再写入 document/window 等页面共享对象,仅把
  序列化后的 GM.info(纯 JSON 数据,无 GM_* 权限方法)内嵌进 module 源码,
  并为 import 解析失败/被页面 CSP 拦截等场景挂上可观测的 error 处理。
- restoreJSCodeFromCompiledResource() 补上同一层 module 包装:此前扩展重启
  或重新注册脚本后,走缓存恢复路径会退回 classic-script 编译,静态
  import/export 随之失效。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031

This comment was marked as outdated.

上一 commit 把 @script-module 限制为仅 @grant none 并只暴露序列化的
GM.info,这会让该功能失去意义——ESM + GM.* 同时可用本来就是这个功能的核心
诉求,而非仅支持静态 import。

恢复原有的 document.<临时属性> 转交机制:module 拿到的 GM 就是按 @grant
实际授予的能力对象(本项目按设计只暴露 GM 而非旧式 GM_* 全局,二者本无需
在 ESM 模式下都支持),window/unsafeWindow/top/parent/globalThis 同上。
early-start 组合同样恢复支持(GM_info 与外层沙盒共享同一对象引用,后续
补齐 isIncognito 等字段时 module 侧能同步看到)。

仅保留两处改动:
- @require 与 @script-module 互斥仍然拒绝——这是 module 无法访问外层
  classic-script 词法变量的客观限制,与本次回退的权限范围无关。
- wrapScriptModuleCode() 新增的 error 事件监听保留,用于在 import 解析
  失败或被页面 CSP 拦截时输出可定位到脚本名的 console.error。

restoreJSCodeFromCompiledResource() 的缓存恢复路径修复(此前该路径完全
跳过 module 包装)不受本次回退影响,继续生效。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

补充说明:上一条评论中提到的「首版强制要求 @grant none,module 仅暴露序列化的 GM.info」已在新 commit 4b51374b 中撤回——那个限制过度收窄了功能本身(ESM + 完整 GM.* 能力本来就是这个 feature 的核心诉求,而不只是支持静态 import)。

现在恢复为:@script-module 仍通过 document.<临时属性>GM(按 @grant 实际授予的能力)、windowunsafeWindowtopparentglobalThis 转交给新建的 <script type="module">,与本 PR 最初的设计一致;GM_*(旧式全局)按设计不在 ESM 模式下暴露,只暴露 GM 命名空间对象。

保留的两处改动:

  • @require@script-module 互斥仍然拒绝(module 无法访问外层 classic-script 的词法变量,这与权限范围无关,是纯粹的作用域限制)。
  • restoreJSCodeFromCompiledResource() 缓存恢复路径的修复继续生效(此前该路径完全跳过 module 包装,扩展重启/重新注册脚本后会静默退回 classic-script 编译)。
  • module 注入失败(import 解析失败/被页面 CSP 拦截)时新增了 error 事件监听,输出可定位到脚本名的 console.error

关于 document.<临时属性> 转交机制本身是否需要进一步加固,还是维持现状,交由维护者判断——如果需要,我可以再评估一次是否有不牺牲 ESM 顶层语义(顶层 import/export 位置自由、真正的顶层 await)的替代方案。

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] ScriptCat 应能注入 <script type="module">

2 participants