在 Chrome 插件中拦截网络请求

本文最后更新于:2025年1月14日 早上

动机

在实现 Chrome 插件 Mass Block Twitter 时,需要批量屏蔽 twitter spam 用户,而 twitter 的请求 header 包含的 auth 信息似乎是通过 js 动态生成的,所以考虑到与其检查 twitter 的 auth 信息是如何生成的,还不如拦截现有的网络请求,记录使用的所有 header,然后在调用 /i/api/1.1/blocks/create.json 接口时直接使用现成的 headers。因而出现了拦截 xhr 的需求,之前也遇到过需要拦截 fetch 请求的情况,而目前现有的库并不能满足需要。

1736254864238.jpg

已经调查的库包括

  • mswjs: 一个 mock 库,可以拦截 xhr/fetch 请求,但是需要使用 service worker,而这对于 Chrome 插件的 Content Script 来说是不可能的。
  • xhook: 一个拦截库,可以拦截 xhr 请求,但无法拦截 fetch 请求,而且最后一个版本是两年前,似乎不再有人维护了

因此,吾辈打算自己实现一个。

设计

首先,吾辈思考了自己的需求

  1. 拦截 fetch/xhr 请求
  2. 支持修改 request url 以实现代理请求
  3. 支持调用原始请求并修改 response
  4. 支持 response sse 流式响应

确定了需求之后吾辈开始尝试设计 API,由于之前使用过非常优秀的 Web 框架 hono,所以吾辈希望 API 能够尽可能的简单,就像 hono 的 Middleware 那样。

下面借用 hono 官方的 Middleware 的洋葱图 [1]

1736247259291.jpg

例如下面使用了两个 Middleware

1
2
3
4
5
6
7
8
9
10
11
app
.use(async (c, next) => {
console.log('middleware 1 before')
await next()
console.log('middleware 1 after')
})
.use(async (c, next) => {
console.log('middleware 2 before')
await next()
console.log('middleware 2 after')
})

实际运行结果将会如下,因为最早注册的 Middleware 将在“洋葱”的最外层,在请求处理开始时最先执行,在请求完成后最后执行。

1
2
3
4
5
middleware 1 before
middleware 2 before
// 实际处理请求...
middleware 2 after
middleware 1 after

实现

接下来就涉及到具体实现 fetch/xhr 的请求拦截了,这里不会给出完整的实现代码,而是更多给出实现思路,最后将会链接到实际的 GitHub 仓库。

fetch

首先说 fetch,它的拦截还是比较简单的,因为 fetch 本身只涉及到一个函数,而且输入与输出比较简单。

先看一个简单的 fetch 使用示例

1
2
3
fetch('https://api.github.com/users/rxliuli')
.then((res) => res.json())
.then((data) => console.log(data))

基本思路就是重写 globalThis.fetch,然后使用自定的实现运行 middlewares,并在合适的时机调用原始的 fetch。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function interceptFetch(...middlewares: Middleware[]) {
const pureFetch = globalThis.fetch
globalThis.fetch = async (input, init) => {
// 构造一个 Context,包含 request 和 response
const c: Context = {
req: new Request(input, init),
res: new Response(),
type: 'fetch',
}
// 运行 middlewares,由于处理原始请求需要在“洋葱”最内层运行,所以处理原始请求实现为一个 middleware
await handleRequest(c, [
...middlewares,
async (context) => {
context.res = await pureFetch(c.req)
},
])
// 返回处理后的 response
return c.res
}
}

// 以洋葱模型运行所有的 middlewares
async function handleRequest(context: Context, middlewares: Middleware[]) {
const compose = (i: number): Promise<void> => {
if (i >= middlewares.length) {
return Promise.resolve()
}
return middlewares[i](context, () => compose(i + 1)) as Promise<void>
}
await compose(0)
}

现在,可以简单的拦截所有的 fetch 请求了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
interceptFetch(
async (context, next) => {
console.log('fetch interceptor 1')
await next()
console.log('fetch interceptor 1 after')
},
async (context, next) => {
console.log('fetch interceptor 2')
await next()
console.log('fetch interceptor 2 after')
},
)
fetch('https://api.github.com/users/rxliuli')
.then((res) => res.json())
.then((data) => console.log(data))
// 输出
// fetch interceptor 1
// fetch interceptor 2
// fetch interceptor 1 after
// fetch interceptor 2 after
// {
// "login": "rxliuli",
// "id": 24560368,
// "node_id": "MDQ6VXNlcjI0NTYwMzY4",
// "avatar_url": "https://avatars.githubusercontent.com/u/24560368?v=4",
// ...
// }

有人可能会有疑问,这与 hono 的 Middleware API 也不一样啊?别着急,API 在最外层包一下就好了,先实现关键的请求拦截部分。

xhr

接下来是 xhr,它与 fetch 非常不同,先看一个简单的 xhr 使用示例

1
2
3
4
5
6
const xhr = new XMLHttpRequest()
xhr.open('GET', 'https://api.github.com/users/rxliuli')
xhr.onload = () => {
console.log(xhr.responseText)
}
xhr.send()

可以看出,xhr 涉及到多个方法,例如 open/onload/send 等等,所以需要重写多个方法。而且由于 middlewares 只应该运行一次,而 xhr 的 method/url 与 body 是分步传递的,所以在实际调用 send 之前,都不能调用原始 xhr 的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function interceptXhr(...middlewares: Middleware[]) {
const PureXhr = XMLHttpRequest
XMLHttpRequest = class extends PureXhr {
#method: string = ''
#url: string | URL = ''
#body?: Document | XMLHttpRequestBodyInit | null
// 重写 open 方法,在调用原始 open 方法之后,仅记录参数
open(method: string, url: string) {
this.#method = method
this.#url = url
}
// 保存所有事件监听器
#listeners: [
string,
(this: XMLHttpRequest, ev: ProgressEvent) => any,
boolean,
][] = []
set onload(callback: (this: XMLHttpRequest, ev: ProgressEvent) => any) {
this.#listeners.push(['load', callback, false])
}
// 重写 send 方法,在调用原始 send 方法之前,运行 middlewares
async send(body?: Document | XMLHttpRequestBodyInit | null) {
this.#body = body
const c: Context = {
req: new Request(this.#url, {
method: this.#method,
body: this.#body as any,
}),
res: new Response(),
type: 'xhr',
}
// 绑定注册的事件监听器
this.#listeners.forEach(([type, listener, once]) => {
super.addEventListener.apply(this, [type, listener as any, once])
})
// 运行 middlewares
await handleRequest(c, [
...middlewares,
async (c) => {
super.addEventListener('load', () => {
// 设置响应
c.res = new Response(this.responseText, { status: this.status })
})
super.send.apply(this, [c.req.body as any])
},
])
}
}
}

现在实现了一个非常基本的 xhr 拦截器,可以记录和修改 request 的 method/url/body,还能记录 response 的 status/body。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
interceptXhr(async (c, next) => {
console.log('method', c.req.method, 'url', c.req.url)
await next()
console.log('json', await c.res.clone().json())
})
const xhr = new XMLHttpRequest()
xhr.open('GET', 'https://api.github.com/users/rxliuli')
xhr.onload = () => {
console.log(xhr.responseText)
}
xhr.send()
// 输出
// method GET url https://api.github.com/users/rxliuli
// json {
// "login": "rxliuli",
// "id": 24560368,
// "node_id": "MDQ6VXNlcjI0NTYwMzY4",
// "avatar_url": "https://avatars.githubusercontent.com/u/24560368?v=4",
// ...
// }

当然,目前 xhr 的实现还非常简陋,没有记录所有 onload/onerror/onreadystatechange 事件,也没有记录所有 header,更不能修改 response。但作为一个演示,整体的实现思路已经出来了。

更多

目前已经实现了完整的 fetch/xhr 拦截器,发布至 npm 上的 @rxliuli/vista 包中,欢迎使用。


在 Chrome 插件中拦截网络请求
https://blog.rxliuli.com/p/7ffe39eff5c64f5d90acf21518e39d63/
作者
rxliuli
发布于
2025年1月2日
许可协议