架构设计

仓库结构

req-freedom/
├── apps/
│   ├── extension/          # 浏览器插件(WXT + React,MV3)
│   │   ├── entrypoints/
│   │   │   ├── background.ts            # 同步规则到 declarativeNetRequest
│   │   │   ├── bridge.content.ts        # ISOLATED world,读 storage 并推送规则
│   │   │   ├── interceptor.content.ts   # MAIN world,fetch/XHR 补丁(Mock、网络限速、改请求体)
│   │   │   ├── popup/                   # 快速启停界面
│   │   │   └── options/                 # 规则管理界面
│   │   └── utils/          # storage 封装、DNR 规则转换
│   └── docs/               # 文档站(Rspress)
└── packages/
    ├── shared/             # 枚举、常量、类型定义
    └── core/               # 平台无关的规则匹配引擎

双通道拦截架构

不同能力由两条链路分别承载:

┌──────────────────────────────┐
                     │   storage.local(规则存储)   │
                     └──────┬──────────────┬────────┘
                            │              │ storage.onChanged
                 onChanged  │              ▼
                            │      ┌────────────────────┐
                            ▼      │ bridge.content.ts  │ (ISOLATED)
                  ┌──────────────┐ └────────┬───────────┘
                  │ background   │          │ MessagePort
                  └──────┬───────┘          ▼
                         │         ┌──────────────────────┐
        updateDynamicRules│        │ interceptor.content  │ (MAIN)
                         ▼         │ fetch / XHR 补丁      │
              ┌────────────────┐   │ + 脚本 / 样式注入     │
              │ declarativeNet │   └──────────┬───────────┘
              │ Request (DNR)  │              │
              └──────┬─────────┘              ▼
                     │        返回值 Mock、网络限速、脚本注入、改请求体
                     ▼
        拦截、重定向、参数注入、Header 改写
  • DNR 通道:拦截 / 重定向 / 参数注入 / Header 改写在网络层由浏览器原生执行,性能好、覆盖所有请求(包括页面导航)
  • 页面补丁通道:返回值 Mock、网络限速与改请求体无法由 DNR 表达,通过 MAIN world 内容脚本改写 fetch 与 XMLHttpRequest 实现,仅作用于页面脚本发起的请求;SSE Mock 额外代理原生 EventSource,并以 ReadableStream 支持 fetch 流式消费,未命中时回落原生实现;fetch 的响应流可按下行带宽精确交付,XHR 则仅能模拟请求前的延迟和上行带宽;改请求体在请求发出前替换或 JSON 深合并请求体;脚本注入亦复用该通道,按页面 URL 命中后注入自定义 JS / CSS
    • 只注入顶层文档,子框架内的请求不经过该通道;同步 XHR 也不在作用范围内,二者均见下方已知限制

命中统计

统计的唯一原始数据是命中日志(RuleHit:规则 ID、动作类型、请求 URL、方法、时间、执行结果)。界面上的各种投影都由它派生,不单独维护计数器。

  • 图标徽标只表达状态,不表达数量:全局停用时显示 OFF;全局启用时,当前标签页有任意规则生效则点亮一个小圆点。popup 展示按规则去重后的命中规则数,逐规则以图标标记而非数字角标——次数信息仍完整保留在命中日志里,留给后续的请求日志视图。
  • DNR 通道的命中来自观测式 chrome.webRequest。onRuleMatchedDebug 仅未打包扩展可用,getMatchedRules 只有 pull 且受 20 次 / 10 分钟配额与 5 分钟保留窗口限制,都无法驱动实时状态。观测到请求后由 core.findMatchedRules 判定命中——与页面补丁通道完全同一个匹配器。
    • 这是预测而非事实:网络层真正执行的是编译出的 DNR 规则。utils/dnr-match-parity.test.ts 守护两者的语义一致性。
    • 预测以实际注册成功的动作为准,而非规则声明了哪些动作。DNR 规则由浏览器校验,非法规则会被拒绝且不会执行;把它们算作命中等于宣称一条没生效的规则生效了。注册结果由提交阶段逐条回填进快照,同时写入 storage.session 供 options 与 popup 标出「未被浏览器接受」的规则。
    • 子资源监听按需注册:不存在启用的 DNR 通道规则时注销,避免无谓唤醒 Service Worker。顶层文档监听常驻,否则没有规则时导航重置会一并失效。
  • 页面补丁通道逐条自报。执行计划(utils/page-plan.ts)在决定动作的同时产出命中记录,不做事后推导。
    • 命中记录带执行结果:applied 表示动作真的发生了,skipped 表示规则匹配上、但浏览器限制导致本次请求无法应用(不透明响应读不到响应体、同步 XHR 容不下异步处理),此时一律 fail-open 原样放行。两者用判别联合表达,跳过必带原因。
    • 因此 Mock 的命中不随计划一起上报:短路 Mock 必定应用,而「基于真实响应」的 Mock 要等真实响应回来才知道能否读取。限速与改请求体则在计划成立时即可上报。
    • skipped 不计入命中;popup 用单独的状态位标出,让「规则匹配了却没生效」有处可看,而不是只在页面控制台留一行 warn。徽标同样不因它点亮——徽标的判据是「日志里有已执行的命中」,而不是「日志非空」,否则会出现徽标说有规则生效、popup 说什么都没生效的矛盾。
  • 状态以内存为权威,storage.session 只作防抖镜像。命中是逐请求写入的,若以 storage 为权威,每条命中都要全量序列化整个数组。
    • 镜像顺带承担 popup 的实时刷新:popup 首次读取走消息(取权威值、无等待),随后订阅镜像变化。这样既不轮询、也不必为刷新界面额外唤醒 Service Worker,代价是最多滞后一个防抖窗口。
    • 冷启动恢复时与存活标签页对账:tabs.onRemoved 若因崩溃或扩展更新的事件真空期而丢失,镜像里会留下等不到回收的孤儿日志;查询失败时全量恢复,不做回收。
    • 全局预算按标签页数量设:单标签页 1000 条的上限约束不了标签页数量,而 storage.session 配额是全局的。超出上限时整份丢弃最久未更新的标签页,而不是跨标签页削减条数——后者会让某个标签页的数字变成静默的半截。活跃度由日志中最后一条命中的时间还原,镜像无需额外字段。
  • 重置点是顶层 main_frame 请求(webRequest.onBeforeRequest)。重置与该请求自身的命中来自同一事件,天然有序,因此不需要统计窗口时间戳或 Document token。
    • 顶层文档请求由常驻监听独占处理:重置与记录是同一个回调里的两条相邻语句,子资源监听显式跳过 main_frame。拆成两个监听器时「重置先于记录」只能依赖派发顺序,而 webRequest 并未承诺同一扩展内多个观测监听器的先后。
    • 重定向跳按 requestId 与新导航区分:主文档被重定向时会以同一 requestId 再次触发 onBeforeRequest,此时不重置,否则这次导航自己的重定向命中会被抹掉。
  • MAIN world 与 ISOLATED world 在 document_start 建立一次 MessageChannel,命中记录只通过私有端口传递;bridge 仍按当前生效规则 ID 校验上报内容。记录时间由接收方盖章,不采信上报值——它参与标签页淘汰的活跃度排序,页面报一个远期时间就能把自己的日志钉住、把别的标签页挤出预算。

已知限制

  • 页面补丁通道只作用于顶层文档。两个内容脚本都未开启 all_frames,因此 iframe(同域与跨域皆然)内部由页面 JS 发起的 fetch / XHR 不经过该通道:Mock、网络限速、改请求体、脚本注入在子框架内一律不生效。命中统计随之也不包含它们——页面补丁的命中是执行的产物,没有执行就没有可记的命中,这里显示为零是如实反映,而非漏记。
    • 与「iframe 的文档请求本身拦不到」是两回事:后者说的是请求由浏览器而非页面 JS 发起(document / img / iframe 一类),前者说的是文档层级。
    • DNR 通道不受此限:它在网络层生效,覆盖所有 frame。因此同一条规则改走 DNR 通道会在子框架内照常执行、照常计入命中。需要覆盖 iframe 且动作是拦截 / 重定向 / 参数注入 / Header 改写时,选 DNR 通道即可。
  • 页面加载极早期(规则尚未通过 MessagePort 送达时)发起的请求不会被 Mock / 延迟
  • 同步 XHR(open(..., false))一律原样放行,页面补丁规则不生效。该通道的处理全是异步的——读请求体、执行动态函数、发影子请求都要等微任务或事件;而同步 XHR 要求 send 返回时响应已就绪,插进去只会让页面读到空响应。这里刻意选择 fail-open:宁可规则不生效,也不破坏页面。命中过页面补丁规则时会在页面控制台提示一次,并在 popup 中把该规则标为「匹配上但未应用」,便于排查「为什么规则没生效」。DNR 通道的规则不受影响,仍在网络层照常执行
  • SSE Mock 不作用于 XHR。XHR 的渐进式 responseText 不能替换为自定义 ReadableStream,因此 SSE 动作会被忽略;同一规则内的限速等其他可执行动作仍照常处理。SSE 请通过 fetch 或原生 EventSource 消费。