pi-browser-use-lite 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +22 -0
  3. package/README.md +152 -2
  4. package/README.zh-CN.md +153 -0
  5. package/browser-extension/background/capture.js +267 -0
  6. package/browser-extension/background/dispatch.js +231 -0
  7. package/browser-extension/background/index.js +66 -0
  8. package/browser-extension/background/input/debugger.js +185 -0
  9. package/browser-extension/background/input/keyboard.js +230 -0
  10. package/browser-extension/background/input/pointer.js +223 -0
  11. package/browser-extension/background/input/upload.js +93 -0
  12. package/browser-extension/background/ledger.js +53 -0
  13. package/browser-extension/background/page/evaluate.js +93 -0
  14. package/browser-extension/background/page/helpers.js +28 -0
  15. package/browser-extension/background/page/screenshot.js +106 -0
  16. package/browser-extension/background/page/snapshot.js +363 -0
  17. package/browser-extension/background/page/waitFor.js +64 -0
  18. package/browser-extension/background/tabs.js +182 -0
  19. package/browser-extension/background/target.js +116 -0
  20. package/browser-extension/background/transport.js +144 -0
  21. package/browser-extension/manifest.json +19 -0
  22. package/browser-extension/page/snapshot.js +255 -0
  23. package/package.json +41 -5
  24. package/src/bridge/index.ts +660 -0
  25. package/src/commands/index.ts +112 -0
  26. package/src/config/index.ts +79 -0
  27. package/src/config/schema.ts +120 -0
  28. package/src/config/validate.ts +396 -0
  29. package/src/format/index.ts +173 -0
  30. package/src/fs/index.ts +103 -0
  31. package/src/index.ts +125 -0
  32. package/src/policy/index.ts +73 -0
  33. package/src/protocol/index.ts +136 -0
  34. package/src/tools/errors.ts +66 -0
  35. package/src/tools/exec/act.ts +351 -0
  36. package/src/tools/exec/cdp.ts +27 -0
  37. package/src/tools/exec/common.ts +186 -0
  38. package/src/tools/exec/index.ts +65 -0
  39. package/src/tools/exec/see.ts +107 -0
  40. package/src/tools/exec/tab.ts +75 -0
  41. package/src/tools/exec/vision.ts +56 -0
  42. package/src/tools/index.ts +450 -0
  43. package/src/tools/result.ts +56 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # CHANGELOG
2
+
3
+ 本文件记录已发布版本的用户可见变更。更细的过程见 git log。破坏性变更标 **BREAKING**。
4
+
5
+ ## 0.1.1 — 2026-11-09
6
+
7
+ - 补中文 README:`README.zh-CN.md`,英文版顶部有切换链接,npm 包同时收录两份。
8
+
9
+ ## 0.1.0 — 2026-11-08
10
+
11
+ 首版。
12
+
13
+ - 五个工具:`chrome_act`(navigate、click、type、fill、press、hover、drag、scroll、upload、eval、wait、batch)、`chrome_see`(refs、text、changes、full,另可取 console 与 network 日志)、`chrome_tab`(list、new、switch、close)、`chrome_vision`(截图落盘)、`chrome_cdp`(裸 CDP 调用)。
14
+ - `/chrome` 命令:`authorize on|off`、`background on|off`、`status`。扩展装好之前,命令与工具都报 `extension_missing` 并给出装扩展三步。
15
+ - 本地桥:只绑 `127.0.0.1`,多 Pi 会话共用一个服务端,其余退成客户端。扩展每 100 毫秒轮询一次。
16
+ - 每个会话一个独立的浏览器窗口:动作在本次会话的 target 里执行,别的窗口不动,退出时按配置关掉标签。
17
+ - 截图默认落在 `<agent dir>/pi-browser-use-lite/screenshots`,跟着 `PI_CODING_AGENT_DIR` 走,不写进家目录的 `~/.pi/screenshots`。
18
+ - 发布:`CHANGELOG.md` 与 `browser-extension/` 一起进 `files`。用户从 npm 装到扩展源码后 load unpacked。
19
+ - 扩展版本落后于包时自己重载一次,不必再去扩展页手动刷新。
20
+ - 轮询断了由扩展的 1 秒定时器重起;`chrome.alarms` 只作服务工作进程被回收后的兜底。
21
+ - `chrome_act eval` 省掉超长 `data` 时,说明接在正文末尾;此前这条说明到不了模型。
22
+ - 包内带 `README.md`:安装、配置、开发各一节,扩展目录由 `/chrome status` 打印。
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 pi-chrome contributors
4
+ Copyright (c) 2026 setwhite
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,153 @@
1
- # Temporary Holding Version
1
+ # pi-browser-use-lite
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [简体中文](README.zh-CN.md) | **English**
4
+
5
+ Five `chrome_*` tools that drive your own browser: the tabs you are already logged into, with your cookies and your sessions.
6
+
7
+ The package has two halves. The pi extension registers the tools and runs a local bridge on `127.0.0.1`. The companion Chrome extension polls that bridge and runs each command through `chrome.debugger`.
8
+
9
+ ## Tools
10
+
11
+ | Tool | What it does |
12
+ |---|---|
13
+ | `chrome_act` | navigate, click, type, fill, press, hover, drag, scroll, upload, eval, wait, batch |
14
+ | `chrome_see` | snapshot the page as refs, text, changes or full; read console and network logs |
15
+ | `chrome_tab` | list, new, switch, close |
16
+ | `chrome_vision` | screenshot the viewport or the full page |
17
+ | `chrome_cdp` | one raw CDP call for anything the other four do not cover |
18
+
19
+ Behaviour worth knowing:
20
+
21
+ - Each session gets its own browser window. The tools do not touch your own tabs. The tabs the session opened are closed when the session ends.
22
+ - Input goes through trusted CDP events, so pages that reject synthetic events still react.
23
+ - Console and network capture starts at navigation, not at the first read.
24
+ - Every failure carries a class and a `hint` that says what to do next.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pi install npm:pi-browser-use-lite
30
+ ```
31
+
32
+ The extension ships inside the package. Load it once per browser:
33
+
34
+ 1. Run `/chrome status` in pi. When the extension is missing, the reply prints the directory to load.
35
+ 2. Open `chrome://extensions`, turn on Developer mode, click Load unpacked and pick that directory.
36
+ 3. Run `/chrome status` again. `connected: yes` means the bridge sees the extension.
37
+
38
+ Then authorize the tools for this session:
39
+
40
+ ```
41
+ /chrome authorize on
42
+ ```
43
+
44
+ Authorization ends with the session. Set `policy.authorizeOnStart` to skip this step.
45
+
46
+ ## Configuration
47
+
48
+ One JSON file: `<agent dir>/pi-browser-use-lite/config.json`. The agent dir is `PI_CODING_AGENT_DIR`, or `~/.pi/agent` when the variable is unset.
49
+
50
+ Without the file every value is the default. A wrong type or an unknown key fails startup and names every bad field at once. An unknown enum value falls back to the default. An out-of-range number is clamped to the nearest bound. The comments below only explain. A real file must be strict JSON.
51
+
52
+ **Minimal configuration**: nothing. All five tools work on the defaults.
53
+
54
+ ```json
55
+ {}
56
+ ```
57
+
58
+ **Full example**: every key follows with its default and when to change it.
59
+
60
+ ```jsonc
61
+ {
62
+ // The local bridge. The extension has the same port compiled in
63
+ // (browser-extension/background/transport.js): change both, then reload the extension
64
+ "bridge": { "port": 17318 },
65
+
66
+ // Session rules
67
+ "policy": {
68
+ "authorizeOnStart": false, // true = the tools work without /chrome authorize on
69
+ "background": true, // true = block every focusing call, chrome_tab switch included.
70
+ // Turn it off when the model must bring a page to the front
71
+ "closeOwnTabsOnExit": true // close the tabs this session opened when the session ends
72
+ },
73
+
74
+ // Input and timing
75
+ "input": {
76
+ "dragSteps": 10, // mouse moves between press and release
77
+ "scrollSteps": 5 // wheel events per scroll call
78
+ },
79
+ "timing": { "navigateTimeoutMs": 15000 }, // wait for the load event; the navigate call gets this plus 2s
80
+
81
+ // Model-visible output
82
+ "output": {
83
+ "maxElements": 80, // snapshot element cap
84
+ "textChars": 18000 // body text cap in see mode=text; the rest is dropped with a note
85
+ },
86
+
87
+ // Screenshots
88
+ "screenshot": {
89
+ "dir": "/home/me/.pi/agent/pi-browser-use-lite/screenshots", // created on first use
90
+ "format": "png", // png | jpeg
91
+ "keepNewest": 20 // older screenshots in that directory are deleted
92
+ },
93
+
94
+ // Global rules for the system prompt. These replace the three defaults; they do not extend them
95
+ "prompt": {
96
+ "guidelines": ["chrome_act: this is the user's real browser; confirm before destructive actions."]
97
+ },
98
+
99
+ // Tool switches and names. Keep at least one enabled; names must be unique and must not shadow a host tool
100
+ "tools": {
101
+ "chrome_act": { "enabled": true, "name": "chrome_act" },
102
+ "chrome_see": { "enabled": true, "name": "chrome_see" },
103
+ "chrome_tab": { "enabled": true, "name": "chrome_tab" },
104
+ "chrome_vision": { "enabled": true, "name": "chrome_vision" },
105
+ "chrome_cdp": { "enabled": true, "name": "chrome_cdp" }
106
+ },
107
+
108
+ // Override the built-in prompts per tool. "" clears description or promptSnippet; [] adds no rules
109
+ "guidance": {
110
+ "chrome_act": {
111
+ "description": "Drive the user's real browser.",
112
+ "promptSnippet": "Drive the browser",
113
+ "promptGuidelines": ["chrome_act: pass see: refs on navigate."]
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ Every key is optional. `src/config/schema.ts` holds the ranges and the defaults.
120
+
121
+ ## Docs
122
+
123
+ | Document | Content |
124
+ |---|---|
125
+ | [ARCHITECTURE](docs/ARCHITECTURE.md) | Module boundaries and seams; read it before changing the structure |
126
+ | [GLOSSARY](docs/GLOSSARY.md) | One name per concept |
127
+ | [Modules](docs/modules/) | Trade-offs and traps per module |
128
+ | [Decisions](docs/decisions/) | Why the shape is what it is |
129
+
130
+ ## Development
131
+
132
+ From the repository root:
133
+
134
+ ```bash
135
+ pnpm -C packages/pi-browser-use-lite lint
136
+ pnpm -C packages/pi-browser-use-lite check:imports
137
+ pnpm -C packages/pi-browser-use-lite typecheck
138
+ pnpm -C packages/pi-browser-use-lite test
139
+ ```
140
+
141
+ The end-to-end test drives a real browser. Load the extension from `packages/pi-browser-use-lite/browser-extension`, keep one Chromium browser open with that extension enabled, then run:
142
+
143
+ ```bash
144
+ pnpm -C packages/pi-browser-use-lite test:e2e
145
+ ```
146
+
147
+ Close every other browser that has this extension. Two copies poll the same bridge, and the commands go to whichever copy asks first.
148
+
149
+ The extension reloads itself when the bridge reports a different version. After editing extension code without a version bump, reload it by hand on the extensions page.
150
+
151
+ ## License
152
+
153
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,153 @@
1
+ # pi-browser-use-lite
2
+
3
+ **[简体中文](README.zh-CN.md)** | [English](README.md)
4
+
5
+ 五个 `chrome_*` 工具,操作你自己的浏览器:你已经登录的标签页、你的 cookie 和会话。
6
+
7
+ 包分两半。pi 扩展注册这些工具,并在 `127.0.0.1` 上跑一个本地桥。配套的 Chrome 扩展轮询这个桥,用 `chrome.debugger` 执行每条命令。
8
+
9
+ ## 工具
10
+
11
+ | 工具 | 做什么 |
12
+ |---|---|
13
+ | `chrome_act` | navigate、click、type、fill、press、hover、drag、scroll、upload、eval、wait、batch |
14
+ | `chrome_see` | 快照:refs、text、changes、full;读控制台与网络日志 |
15
+ | `chrome_tab` | list、new、switch、close |
16
+ | `chrome_vision` | 截视口或整页 |
17
+ | `chrome_cdp` | 一条裸 CDP 调用,另四个盖不住的用它 |
18
+
19
+ 值得知道的行为:
20
+
21
+ - 每个会话一个独立浏览器窗口。工具不碰你自己的标签页。会话结束时关掉本会话开的标签。
22
+ - 输入走可信 CDP 事件,拒绝合成事件的页面也会响应。
23
+ - 控制台与网络抓取从导航开始,不从第一次读取开始。
24
+ - 每次失败都带类别和一句 `hint`,说明下一步做什么。
25
+
26
+ ## 安装
27
+
28
+ ```bash
29
+ pi install npm:pi-browser-use-lite
30
+ ```
31
+
32
+ 扩展就在包里。每个浏览器装一次:
33
+
34
+ 1. 在 pi 里运行 `/chrome status`。扩展没连上时,回复会打印要加载的目录。
35
+ 2. 打开 `chrome://extensions`,开开发者模式,点「加载已解压的扩展程序」,选那个目录。
36
+ 3. 再跑一次 `/chrome status`。`connected: yes` 表示桥看到了扩展。
37
+
38
+ 然后给这次会话授权:
39
+
40
+ ```
41
+ /chrome authorize on
42
+ ```
43
+
44
+ 授权随会话结束。想省这一步,把 `policy.authorizeOnStart` 设为 true。
45
+
46
+ ## 配置
47
+
48
+ 一个 JSON 文件:`<agent dir>/pi-browser-use-lite/config.json`。agent dir 是 `PI_CODING_AGENT_DIR`,没设时是 `~/.pi/agent`。
49
+
50
+ 没有这个文件时全部取默认值。类型错或未知键会让启动失败,并一次列出所有坏字段。枚举值不认识时取默认值。数字越界时夹到最近的边界。下面的注释只作解释,真正的文件必须是严格 JSON。
51
+
52
+ **最小配置**:不用写。五个工具在默认值下都能用。
53
+
54
+ ```json
55
+ {}
56
+ ```
57
+
58
+ **完整示例**:每个键都跟着默认值,以及什么时候该改。
59
+
60
+ ```jsonc
61
+ {
62
+ // 本地桥。扩展里编译了同一个端口
63
+ // (browser-extension/background/transport.js):两处一起改,然后重载扩展
64
+ "bridge": { "port": 17318 },
65
+
66
+ // 会话规则
67
+ "policy": {
68
+ "authorizeOnStart": false, // true = 不用 /chrome authorize on 就能用工具
69
+ "background": true, // true = 挡掉所有聚焦动作,chrome_tab switch 也算。
70
+ // 需要模型把页面带到前台时关掉
71
+ "closeOwnTabsOnExit": true // 会话结束时关掉本会话开的标签
72
+ },
73
+
74
+ // 输入与时限
75
+ "input": {
76
+ "dragSteps": 10, // 按下与松开之间的鼠标移动次数
77
+ "scrollSteps": 5 // 每次 scroll 的滚轮事件数
78
+ },
79
+ "timing": { "navigateTimeoutMs": 15000 }, // 等 load 事件的时长;navigate 调用再多给 2 秒
80
+
81
+ // 模型可见的输出
82
+ "output": {
83
+ "maxElements": 80, // 快照元素上限
84
+ "textChars": 18000 // see 的 text 模式正文上限,其余带提示丢掉
85
+ },
86
+
87
+ // 截图
88
+ "screenshot": {
89
+ "dir": "/home/me/.pi/agent/pi-browser-use-lite/screenshots", // 第一次用时创建
90
+ "format": "png", // png | jpeg
91
+ "keepNewest": 20 // 这个目录里更旧的截图会被删掉
92
+ },
93
+
94
+ // 系统提示里的全局规则。这三条换成你的,不是追加
95
+ "prompt": {
96
+ "guidelines": ["chrome_act: 这是用户真实的浏览器,破坏性操作前先问一句。"]
97
+ },
98
+
99
+ // 工具开关与名字。至少留一个启用;名字不能重复,也不能盖住宿主已有的工具
100
+ "tools": {
101
+ "chrome_act": { "enabled": true, "name": "chrome_act" },
102
+ "chrome_see": { "enabled": true, "name": "chrome_see" },
103
+ "chrome_tab": { "enabled": true, "name": "chrome_tab" },
104
+ "chrome_vision": { "enabled": true, "name": "chrome_vision" },
105
+ "chrome_cdp": { "enabled": true, "name": "chrome_cdp" }
106
+ },
107
+
108
+ // 按工具改内置提示。"" 清空 description 或 promptSnippet;[] 表示不加规则
109
+ "guidance": {
110
+ "chrome_act": {
111
+ "description": "操作用户真实的浏览器。",
112
+ "promptSnippet": "操作浏览器",
113
+ "promptGuidelines": ["chrome_act: navigate 时带上 see: refs。"]
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ 每个键都可选。范围与默认值在 `src/config/schema.ts`。
120
+
121
+ ## 文档
122
+
123
+ | 文档 | 内容 |
124
+ |---|---|
125
+ | [ARCHITECTURE](docs/ARCHITECTURE.md) | 模块边界与 seam;改结构之前先读它 |
126
+ | [GLOSSARY](docs/GLOSSARY.md) | 一个概念只用一个词 |
127
+ | [Modules](docs/modules/) | 每个模块的取舍与坑 |
128
+ | [Decisions](docs/decisions/) | 为什么是这个形状 |
129
+
130
+ ## 开发
131
+
132
+ 在仓库根执行:
133
+
134
+ ```bash
135
+ pnpm -C packages/pi-browser-use-lite lint
136
+ pnpm -C packages/pi-browser-use-lite check:imports
137
+ pnpm -C packages/pi-browser-use-lite typecheck
138
+ pnpm -C packages/pi-browser-use-lite test
139
+ ```
140
+
141
+ 端到端测试会驱动真实浏览器。把 `packages/pi-browser-use-lite/browser-extension` 加载进扩展,留一个开着的 Chromium 系浏览器,然后运行:
142
+
143
+ ```bash
144
+ pnpm -C packages/pi-browser-use-lite test:e2e
145
+ ```
146
+
147
+ 关掉其他装了本扩展的浏览器。两份扩展会抢同一个桥,命令落到先问的那一份。
148
+
149
+ 桥报出的版本与扩展不同的时候,扩展会自己重载。改了扩展代码但没升版本号时,去扩展页手动重载一次。
150
+
151
+ ## 许可证
152
+
153
+ MIT,见 [LICENSE](LICENSE)。
@@ -0,0 +1,267 @@
1
+ /**
2
+ * 日志捕获:用 CDP 事件流记 console 与 network,按标签存一份。
3
+ * 缓冲区在后台而不是页面里,所以同一标签再导航一次也不会丢。
4
+ */
5
+
6
+ import { attach, sendCommand } from "./input/debugger.js";
7
+ import { trackedTabIds } from "./target.js";
8
+
9
+ const KEY_PREFIX = "capture.logs.";
10
+ const CONSOLE_LIMIT = 500;
11
+ const NETWORK_LIMIT = 1_000;
12
+ const BODY_LIMIT = 200_000;
13
+
14
+ /** tabId → { console, network, starts, capturing, offReason } */
15
+ const buffers = new Map();
16
+ /** 正在从存储里读的缓冲区。两个调用同时到时共用同一次读,不各建一份。 */
17
+ const pending = new Map();
18
+ let subscribed = false;
19
+
20
+ subscribe();
21
+
22
+ /** 事件监听在模块加载时注册,service worker 才会被它们唤醒。 */
23
+ function subscribe() {
24
+ const debuggerApi = globalThis.chrome?.debugger;
25
+ if (subscribed || debuggerApi === undefined) return;
26
+ subscribed = true;
27
+ debuggerApi.onEvent.addListener(onEvent);
28
+ debuggerApi.onDetach.addListener(onDetach);
29
+ }
30
+
31
+ /**
32
+ * 在这个标签上开抓。已经开着就只补一次 attach,不重复开域。
33
+ * 挂不上调试器时只记一句说明,不挡动作。
34
+ * @param {number} tabId 标签 id
35
+ * @returns {Promise<void>}
36
+ */
37
+ export async function startCapture(tabId) {
38
+ subscribe();
39
+ const buffer = await bufferOf(tabId);
40
+ buffer.offReason = undefined;
41
+ try {
42
+ await attach(tabId);
43
+ if (buffer.capturing !== true) {
44
+ await sendCommand(tabId, "Runtime.enable");
45
+ await sendCommand(tabId, "Network.enable");
46
+ buffer.capturing = true;
47
+ }
48
+ } catch (error) {
49
+ buffer.capturing = false;
50
+ buffer.offReason = `capture is off for this tab: ${messageOf(error)}`;
51
+ }
52
+ await save(tabId, buffer);
53
+ }
54
+
55
+ /**
56
+ * `chrome.webNavigation.onCommitted` 的处理。只抓本会话台账里的标签。
57
+ * @param {{ tabId?: number, frameId?: number }} details 导航详情
58
+ * @returns {Promise<void>}
59
+ */
60
+ export async function onCommitted({ tabId, frameId } = {}) {
61
+ if (frameId !== 0 || !Number.isInteger(tabId)) return;
62
+ if (!(await trackedTabIds()).has(tabId)) return;
63
+ await startCapture(tabId);
64
+ }
65
+
66
+ /**
67
+ * 读 console 条目,最旧的在前。
68
+ * @param {number} tabId 标签 id
69
+ * @param {{ clear?: boolean }} [options] `clear` 读完清空
70
+ * @returns {Promise<{ records: object[], note?: string }>} 条目与说明
71
+ */
72
+ export async function listConsole(tabId, { clear = false } = {}) {
73
+ const buffer = await bufferOf(tabId);
74
+ const records = buffer.console.slice();
75
+ if (clear) {
76
+ buffer.console.length = 0;
77
+ await save(tabId, buffer);
78
+ }
79
+ return withNote(records, buffer.offReason);
80
+ }
81
+
82
+ /**
83
+ * 读网络请求,最早的在前。
84
+ * @param {number} tabId 标签 id
85
+ * @param {{ clear?: boolean }} [options] `clear` 读完清空
86
+ * @returns {Promise<{ records: object[], note?: string }>} 请求与说明
87
+ */
88
+ export async function listNetwork(tabId, { clear = false } = {}) {
89
+ const buffer = await bufferOf(tabId);
90
+ const records = buffer.network.map((record) => ({ ...record }));
91
+ if (clear) {
92
+ buffer.network.length = 0;
93
+ await save(tabId, buffer);
94
+ }
95
+ return withNote(records, buffer.offReason);
96
+ }
97
+
98
+ /**
99
+ * 单条请求的详情,顺带取回响应体。
100
+ * @param {number} tabId 标签 id
101
+ * @param {string} requestId 请求 id
102
+ * @returns {Promise<object>} 请求记录
103
+ */
104
+ export async function networkRequest(tabId, requestId) {
105
+ const buffer = await bufferOf(tabId);
106
+ const record = buffer.network.find((entry) => entry.id === requestId);
107
+ if (record === undefined)
108
+ throw new Error(`bad_args: no request with id ${requestId}`);
109
+
110
+ const detail = { ...record };
111
+ try {
112
+ const body = await sendCommand(tabId, "Network.getResponseBody", {
113
+ requestId,
114
+ });
115
+ if (body?.base64Encoded === true)
116
+ detail.responseBodyNote = "the response body is binary";
117
+ else if (typeof body?.body === "string")
118
+ detail.responseBody = clipBody(body.body);
119
+ } catch (error) {
120
+ detail.responseBodyNote = `could not read the response body: ${messageOf(error)}`;
121
+ }
122
+ return detail;
123
+ }
124
+
125
+ function onEvent(source, method, params = {}) {
126
+ void handleEvent(source, method, params);
127
+ }
128
+
129
+ /// 调试器事件可能在 startCapture 之前到(service worker 刚醒来),所以先按标签建缓冲区。
130
+ async function handleEvent(source, method, params) {
131
+ const tabId = source?.tabId;
132
+ if (!Number.isInteger(tabId)) return;
133
+ const buffer = await bufferOf(tabId);
134
+ if (buffer.offReason !== undefined) return;
135
+ if (method === "Runtime.consoleAPICalled") pushConsole(buffer, params);
136
+ else if (method === "Network.requestWillBeSent") pushRequest(buffer, params);
137
+ else if (method === "Network.responseReceived") markStatus(buffer, params);
138
+ else if (method === "Network.loadingFinished") markDuration(buffer, params);
139
+ else return;
140
+ await save(tabId, buffer);
141
+ }
142
+
143
+ function onDetach(source, reason) {
144
+ const buffer = buffers.get(source?.tabId);
145
+ if (buffer === undefined) return;
146
+ buffer.capturing = false;
147
+ buffer.offReason = `capture is off for this tab: ${reason}`;
148
+ }
149
+
150
+ function pushConsole(buffer, { type, args = [], timestamp }) {
151
+ buffer.console.push({
152
+ level: levelOf(type),
153
+ text: args.map(textOf).join(" "),
154
+ time: Math.round(timestamp ?? Date.now()),
155
+ });
156
+ if (buffer.console.length > CONSOLE_LIMIT)
157
+ buffer.console.splice(0, buffer.console.length - CONSOLE_LIMIT);
158
+ }
159
+
160
+ function pushRequest(buffer, { requestId, request = {}, timestamp }) {
161
+ const record = {
162
+ id: requestId,
163
+ method: request.method ?? "GET",
164
+ url: request.url ?? "",
165
+ status: undefined,
166
+ durationMs: undefined,
167
+ };
168
+ if (typeof request.postData === "string") {
169
+ record.requestBody = clipBody(request.postData);
170
+ if (record.requestBody !== request.postData)
171
+ record.requestBodyTruncated = true;
172
+ }
173
+ buffer.starts.set(requestId, timestamp);
174
+ buffer.network.push(record);
175
+ if (buffer.network.length > NETWORK_LIMIT)
176
+ buffer.network.splice(0, buffer.network.length - NETWORK_LIMIT);
177
+ }
178
+
179
+ function markStatus(buffer, { requestId, response = {} }) {
180
+ const record = buffer.network.find((entry) => entry.id === requestId);
181
+ if (record !== undefined) record.status = response.status;
182
+ }
183
+
184
+ function markDuration(buffer, { requestId, timestamp }) {
185
+ const record = buffer.network.find((entry) => entry.id === requestId);
186
+ const startedAt = buffer.starts.get(requestId);
187
+ if (record === undefined || startedAt === undefined) return;
188
+ record.durationMs = Math.round((timestamp - startedAt) * 1000);
189
+ }
190
+
191
+ async function bufferOf(tabId) {
192
+ const existing = buffers.get(tabId);
193
+ if (existing !== undefined) return existing;
194
+ let loading = pending.get(tabId);
195
+ if (loading === undefined) {
196
+ const key = KEY_PREFIX + tabId;
197
+ loading = chrome.storage.session.get(key).then((stored) => {
198
+ const saved = stored?.[key] ?? {};
199
+ return {
200
+ console: saved.console ?? [],
201
+ network: saved.network ?? [],
202
+ starts: new Map(Object.entries(saved.starts ?? {})),
203
+ capturing: saved.capturing === true,
204
+ offReason: saved.offReason,
205
+ };
206
+ });
207
+ pending.set(tabId, loading);
208
+ }
209
+ const buffer = await loading;
210
+ buffers.set(tabId, buffer);
211
+ pending.delete(tabId);
212
+ return buffer;
213
+ }
214
+
215
+ async function save(tabId, buffer) {
216
+ await chrome.storage.session.set({
217
+ [KEY_PREFIX + tabId]: {
218
+ console: buffer.console,
219
+ network: buffer.network,
220
+ starts: Object.fromEntries(buffer.starts),
221
+ capturing: buffer.capturing,
222
+ offReason: buffer.offReason,
223
+ },
224
+ });
225
+ }
226
+
227
+ function withNote(records, offReason) {
228
+ return offReason === undefined ? { records } : { records, note: offReason };
229
+ }
230
+
231
+ function levelOf(type) {
232
+ if (type === "warning") return "warn";
233
+ if (type === "error" || type === "assert") return "error";
234
+ if (type === "debug" || type === "trace") return "debug";
235
+ if (type === "info") return "info";
236
+ return "log";
237
+ }
238
+
239
+ function textOf(arg) {
240
+ if (arg?.value !== undefined) return String(arg.value);
241
+ if (typeof arg?.description === "string") return arg.description;
242
+ return String(arg?.type ?? "");
243
+ }
244
+
245
+ /** 按上限切请求体。说明占的字符也算在上限内,丢掉的数量与实际切掉的一致。 */
246
+ function clipBody(text) {
247
+ if (text.length <= BODY_LIMIT) return text;
248
+ let kept = BODY_LIMIT;
249
+ let dropped = text.length - kept;
250
+ // 说明里的数字位数会改变说明本身的长度,迭代到不再变为止。
251
+ for (;;) {
252
+ const next = BODY_LIMIT - bodyNote(dropped).length;
253
+ const nextDropped = text.length - next;
254
+ if (next === kept) break;
255
+ kept = next;
256
+ dropped = nextDropped;
257
+ }
258
+ return text.slice(0, kept) + bodyNote(dropped);
259
+ }
260
+
261
+ function bodyNote(dropped) {
262
+ return `\n[truncated ${dropped} chars]`;
263
+ }
264
+
265
+ function messageOf(error) {
266
+ return error instanceof Error ? error.message : String(error);
267
+ }