cdp-tunnel 3.6.2 → 3.6.4
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.
- package/CHANGELOG.md +266 -0
- package/examples/README.md +235 -0
- package/examples/multi-browser-service.js +238 -0
- package/extension-new/manifest.json +1 -1
- package/extension-new/utils/config.js +1 -1
- package/index.js +210 -0
- package/package.json +5 -2
- package/server/modules/port-pool.js +6 -31
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [3.6.4] - 2026-07-08
|
|
6
|
+
### Added
|
|
7
|
+
- **SDK 入口**:新增 `index.js`,`require('cdp-tunnel')` 直接返回编程式 API(`startServer` / `createApiKey` / `listApiKeys` / `revokeApiKey` / `PortPoolManager` 等),无需深路径 require
|
|
8
|
+
- **多浏览器实例服务示例**:新增 `examples/multi-browser-service.js`,演示"一服务管多浏览器"的完整流程(起 proxy → 创建 key → 启 Chrome → 发现 → 接管 → 操作 → 验证隔离)
|
|
9
|
+
- **多浏览器并发测试**:新增 `tests/e2e/test-multi-browser.js`,覆盖 11 个检查点(服务端发现、key 路由、双向隔离、并发操作、断连容错),已纳入 `run-all.js` core tier
|
|
10
|
+
- **示例文档**:新增 `examples/README.md`,含架构图、快速开始、7 步流程说明、验证点表格、发版前必跑命令
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- `package.json` 的 `main` 从 `server/proxy-server.js` 改为 `index.js`(SDK 入口)
|
|
14
|
+
- `package.json` 的 `files` 加入 `index.js`
|
|
15
|
+
|
|
16
|
+
## [3.6.3] - 2026-07-07
|
|
17
|
+
### Fixed
|
|
18
|
+
- **不再产生隐式 about:blank 页面**:删除端口池 warmup 逻辑(每个 session 第一个 client 连接时用 `Target.createTarget` 创建临时 about:blank tab 再关闭),时间窗口内 Playwright 的 `setAutoAttach` 会看到这个 tab,导致用户看到多余空白页或自动测试断言 `pages().length` 不稳定
|
|
19
|
+
- **对齐原生 CDP**:移除 warmup 后,client 连接后 `pages()` 返回 0(与 `chrome --remote-debugging-port` 一致),不再有隐式页面
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- 更新 test-default-page.js 注释:描述改为"客户端连接后不产生任何隐式 about:blank"
|
|
23
|
+
|
|
24
|
+
## [3.6.2] - 2026-07-07
|
|
25
|
+
### Fixed
|
|
26
|
+
- **内存泄漏**:`Target.targetDestroyed` / `Target.detachedFromTarget` 事件路径清理 `sessionToPort`、`targetToPort`、`sessionToClient`,避免长跑后无界增长
|
|
27
|
+
- **客户端断开时清理**:port-pool 的 `ws.on('close')` 现在清掉对应 client 的 `pendingRequests` 和 `sessionToClient` 引用,避免 plugin 永不响应时 ws 对象常驻
|
|
28
|
+
- **健壮性**:`handleHttpRequest` 加顶层 try/catch,plugin 通信异常不再 crash 整个 proxy
|
|
29
|
+
- **WebSocket 安全发送**:proxy-server.js 中 8 处直接 `ws.send()` 全部改为 `safeSend()`,客户端/插件意外断开时不再触发二次 cleanup race
|
|
30
|
+
- **message listener 泄漏**:`requestVersionFromPlugin` / `requestTargetsFromPlugin` 的超时分支现在会 `off` listener,避免长连接累积
|
|
31
|
+
- **进程退出干净**:SIGINT/SIGTERM 现在清理全部 4 个 `setInterval`(之前只清了 heartbeat)
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- **依赖瘦身**:移除未使用的 devDependencies(`sharp`、`lz-string`、`pako`、`playwright-core`、`puppeteer-core`)
|
|
35
|
+
- **scripts 清理**:删除指向不存在目录的 npm scripts(client / record / benchmark / demo 等 7 条)
|
|
36
|
+
- **.gitignore 增强**:加入 `.vscode/`、`.idea/`、`*.cpuprofile`、`*.heapprofile`
|
|
37
|
+
- **bin 字段规范化**:从 `"./cli/index.js"` 改为 `{ "cdp-tunnel": "./cli/index.js" }`(npm publish 会自动修正,现在显式正确)
|
|
38
|
+
|
|
39
|
+
### Removed
|
|
40
|
+
- 清理 13 个开发期临时脚本(`tests/e2e/_*.cjs`,含硬编码绝对路径)
|
|
41
|
+
|
|
42
|
+
## [3.6.1] - 2026-07-07
|
|
43
|
+
### Fixed
|
|
44
|
+
- **issue #1**:cdp-tunnel 模式下 `Target.getTargetInfo` 返回空 title — 用 `tab.title` 覆盖过期 title
|
|
45
|
+
- **issue #2**:带 clip 的 `Page.captureScreenshot` 缺 elementScreenshot — 自动 `ensureVisible` 前置
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
- 大量死代码清理(autoCreateDefaultPageAndForward、forwardToPlugin、pageCreateIsolatedWorld 等)
|
|
49
|
+
- pre-commit hook 加 `NO_PROXY=localhost,127.0.0.1,::1`,避免 Playwright HTTP 请求走系统代理导致测试假阳性
|
|
50
|
+
|
|
51
|
+
## [3.6.0] - 2026-06-23
|
|
52
|
+
### Added
|
|
53
|
+
- **云管理控制台**:proxy 内置管理网页 `/admin`,查看在线浏览器、管理 key、CDP 快捷操作
|
|
54
|
+
- **Tab 管理**:列表/关闭/切换标签页,关闭浏览器
|
|
55
|
+
- **快捷操作 + 一键演示**:打开百度、截图、执行 JS、自动搜索演示
|
|
56
|
+
- **创建浏览器入口**:直观创建新分组,生成完整地址一键复制
|
|
57
|
+
- **按 key 隔离分组**:每个 key 独立端口池端口 session,listtabs 只看自己的 tab
|
|
58
|
+
- test-key-isolation.js(5 项)、test-admin-console.js(10 项)自动化测试
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
- 分组名按 API Key 名称命名(CDP-张三的浏览器),一眼看出是谁的
|
|
62
|
+
- 扩展端 buildGroupName/_createGroupForClient/doGroup 支持 groupName
|
|
63
|
+
- listtabs 走 /client 路径(按 key 过滤,不入侵用户 tab)
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
- closebrowser 正确关闭分组(clientId 按 key 反查 + client-disconnected)
|
|
67
|
+
- keySessions constructor 补全(git checkout 遗漏)
|
|
68
|
+
- doGroup 实际建分组时 groupName 没传(根因:state 没存 groupName)
|
|
69
|
+
|
|
70
|
+
## [3.5.0] - 2026-06-22
|
|
71
|
+
### Added
|
|
72
|
+
- **API Key 鉴权**:proxy 支持 `REQUIRE_AUTH=true` 强制鉴权,一个 key 绑定一个浏览器。扩展连 `/plugin?key=xxx`,客户端连 `/client?key=xxx`,不同 key 互不可见。新增 `server/saas/key-manager.js` 手动管理 key(创建/列出/吊销)。
|
|
73
|
+
- **版本校验加强**:`STRICT_VERSION=true` 时扩展版本与 proxy 不一致直接拒绝连接(close 4002),避免老扩展与新 proxy 不兼容。
|
|
74
|
+
- **远程部署支持**:proxy 监听 0.0.0.0,可通过 nginx/Cloudflare 反代暴露到公网。新增 `docs/DEPLOY-CLOUDFLARE.md` 部署文档。
|
|
75
|
+
- 新增自动化测试:`test-api-key-auth.js`(鉴权 7 项)、`test-version-check.js`(版本校验 3 项)、`test-prod-deploy.js`(生产环境验证 10 项)。
|
|
76
|
+
- 扩展配置页优化:带 key 的 URL 自动隐藏明文(防截图泄露),显示 🔑 鉴权标识,CDP 地址自动带 key。
|
|
77
|
+
|
|
78
|
+
### Changed
|
|
79
|
+
- **9221 主端口纳入端口池**:9221 不再走老的 create 路径,改为端口池第 0 个 session(pool_9221),与 9231-9239 行为完全一致。takeover(9220)保持原逻辑。
|
|
80
|
+
- 端口池 `/json/version` `/json/list` 支持尾斜杠(修复 Playwright connectOverCDP 404)。
|
|
81
|
+
- pre-commit smoke test 从 3 个增加到 5 个(25 checks)。
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
- 修复 `attachedToTarget` 事件 sessionId 不注册导致 Playwright evaluate 卡死的根因(auto-attach 场景 targetId 可能未注册)。
|
|
85
|
+
- 修复端口池 `closeTarget` 响应 `session.attachedTargets` 字段不存在的 TypeError。
|
|
86
|
+
- 修复 SaaS `auth.js` 的 `datetime("now")` SQL 语法错误(双引号→单引号)。
|
|
87
|
+
- 修复 `validateApiKey` 作用域 bug(定义在 try 块内导致 HAS_SAAS=true 时崩溃)。
|
|
88
|
+
|
|
89
|
+
## [3.0.0] - 2026-06-19
|
|
90
|
+
### Added
|
|
91
|
+
- **端口池架构**:proxy 现在支持多端口隔离。每个 create 端口(默认 9222-9230)= 一个独立的隔离环境,不同端口的 client 互不可见对方的 tab。对齐原生 Chrome `--remote-debugging-port` 行为:多客户端可连同一端口、断开不清理 tab。
|
|
92
|
+
- 新增 `server/modules/port-pool.js`:端口池管理器,按 portIndex 隔离 target/session 事件路由
|
|
93
|
+
- 新增 `test-port-isolation.js`:验证不同端口的 client 互不可见(6 项检查)
|
|
94
|
+
- `run-all.js` 测试时自动禁用端口池(`POOL_SIZE=0`)避免端口冲突
|
|
95
|
+
|
|
96
|
+
### Changed
|
|
97
|
+
- `config.js` 新增 `POOL_TAKEOVER_PORT`(9220)、`POOL_START`(9222)、`POOL_SIZE`(9)配置
|
|
98
|
+
- 现有 9221/9222 行为完全不变(端口池是额外新增的端口)
|
|
99
|
+
|
|
100
|
+
### 配置
|
|
101
|
+
- `POOL_SIZE=0` 禁用端口池(默认 9 个 create 端口)
|
|
102
|
+
- `POOL_START=9222` 端口池起始端口
|
|
103
|
+
- `POOL_TAKEOVER_PORT=9220` 端口池的 takeover 端口
|
|
104
|
+
|
|
105
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
106
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
107
|
+
|
|
108
|
+
## [2.10.14] - 2026-06-19
|
|
109
|
+
### Fixed
|
|
110
|
+
- **长时间运行后并发 createTarget 偶发 page 停留 about:blank**:`sessionToClientId` 映射表泄漏——当 targetId 归属未定时,L900 将 `targetId` 误存为 session 的 value(应为 clientId),导致 `cleanupClient` 按 clientId 匹配时永远清不掉这些条目。proxy 长时间运行后 `sessionToClientId` 无限增长,新 client 的事件可能命中过期映射导致路由错误。修复:归属未定时暂存到 `pendingSessionToClientId`,归属绑定后补绑;`cleanupClient` 补清理 `pendingAttachedEvents`/`pendingTargetCreatedEvents`/`pendingSessionToClientId`。实测 200 次连/断后所有 Map 稳定为 0(修复前 sessionToClientId 线性增长至 300+)。
|
|
111
|
+
|
|
112
|
+
## [2.10.13] - 2026-06-17
|
|
113
|
+
### Fixed
|
|
114
|
+
- **Input.dispatchKeyEvent / dispatchMouseEvent 在隔离 tab 上丢失**:隔离 tab 默认 `visibility=hidden`(`active:false` + 折叠分组),Chromium 在此状态下丢弃合成输入事件(keyboard/mouse),导致 Playwright/Puppeteer 的 `keyboard.type()`、`mouse.click()` 等操作静默失效。`forward.js` 现在在发送合成输入命令前自动执行 `Page.bringToFront` + 等待 `visibilitychange` + 恢复元素焦点,确保事件能投递到 DOM。`insertText` 和 `Runtime.evaluate` 不受影响(不走合成事件路由),无需此处理。
|
|
115
|
+
|
|
116
|
+
### Added
|
|
117
|
+
- E2E 测试 `test-input-delivery.js`:验证 keyboard/mouse/click/Enter 四类合成输入事件在隔离 tab 上能正确投递到 DOM
|
|
118
|
+
|
|
119
|
+
## [2.5.8] - 2026-05-12
|
|
120
|
+
### Fixed
|
|
121
|
+
- 修复 `websocket.js` 中 `startGroupMonitor` 函数末尾代码重复导致的 JS 语法错误(该错误导致扩展完全无法加载)
|
|
122
|
+
|
|
123
|
+
## [2.5.7] - 2026-05-12
|
|
124
|
+
### Fixed
|
|
125
|
+
- 恢复 `addTabToAutomationGroup` 的 `cdpClients[0]` fallback 机制:当 `clientId` 为空时,如果存在已连接 client 则 fallback 到第一个 client 强制分组,避免逃逸
|
|
126
|
+
|
|
127
|
+
## [2.5.6] - 2026-05-12
|
|
128
|
+
### Added
|
|
129
|
+
- `doGroup` 分组失败自动重试 3 次(500ms 间隔),提高首次分组成功率
|
|
130
|
+
- 分组逃逸监控日志增强:记录每个 tab 的 groupId、clientId、预期分组状态
|
|
131
|
+
|
|
132
|
+
### Fixed
|
|
133
|
+
- 分组逃逸监控跳过 `clientId` 为空或 pre-existing 的 tab
|
|
134
|
+
|
|
135
|
+
## [2.5.5] - 2026-05-12
|
|
136
|
+
### Changed
|
|
137
|
+
- 分组逃逸监控间隔从 10 秒缩短为 5 秒,更快检测并修复逃逸 tab
|
|
138
|
+
|
|
139
|
+
## [2.5.4] - 2026-05-12
|
|
140
|
+
### Added
|
|
141
|
+
- `startGroupMonitor()`: 每 10 秒扫描所有 attached tab,检测逃逸(有 clientId 但无 groupId)的 tab,自动强制归组
|
|
142
|
+
|
|
143
|
+
### Fixed
|
|
144
|
+
- Proxy 拦截跨客户端 `Target.closeTarget` 和 `Target.attachToTarget`,返回 `Target is owned by another client` 错误
|
|
145
|
+
- `buildGroupName` 改用末尾 8 位随机后缀,确保各客户端组名唯一
|
|
146
|
+
|
|
147
|
+
## [2.5.3] - 2026-05-12
|
|
148
|
+
### Fixed
|
|
149
|
+
- `buildGroupName` 改用 `clientId.substring(clientId.length - 8)` 取末尾随机后缀,修复所有 client 组名相同(都是 `CDP-client_1`)的问题
|
|
150
|
+
|
|
151
|
+
### Added
|
|
152
|
+
- 压力测试 `test-stress-isolation.js`:3 个并发 CDP 客户端各执行 10+ 次创建/关闭/切换操作,验证隔离性、分组独立性和断连清理
|
|
153
|
+
- `Tab.getGroupInfo` handler:直接查询分组是否创建成功
|
|
154
|
+
|
|
155
|
+
## [2.5.2] - 2026-05-12
|
|
156
|
+
### Added
|
|
157
|
+
- `addTabToAutomationGroup` 改用轮询方案:`chrome.tabs.get` 每 200ms 检查 tab 状态,加载完成后再分组(最多等 4 秒后强制分组),无需 setTimeout(500) 或 chrome.tabs.onUpdated
|
|
158
|
+
|
|
159
|
+
### Fixed
|
|
160
|
+
- `addTabToAutomationGroup` 的分组操作改为等 tab 状态 `complete` 后再执行,确保 `chrome.tabs.group` 调用成功
|
|
161
|
+
|
|
162
|
+
## [2.5.1] - 2026-05-12
|
|
163
|
+
### Fixed
|
|
164
|
+
- CLI 更新命令改用 `npm install -g cdp-tunnel@latest`,修复 `npm update -g` 偶发的 `Cannot read properties of null` 错误
|
|
165
|
+
|
|
166
|
+
## [2.5.0] - 2026-05-12
|
|
167
|
+
### Added
|
|
168
|
+
- 多客户端隔离:CDP 客户端只能看到自己创建的 tab(`Target.getTargets` 过滤)
|
|
169
|
+
- 用户 tab 不可见:`emitAutoAttachForExistingTargets` 跳过非 CDP tab,不发事件不 attach
|
|
170
|
+
- 目标事件缓存:`pendingTargetCreatedEvents` 修复事件先于 response 到达时的丢包问题
|
|
171
|
+
|
|
172
|
+
### Fixed
|
|
173
|
+
- `closeGroupById` 按 `clientId` 过滤 tab,避免断连时误杀其他 client 的 tab
|
|
174
|
+
- `addTabToAutomationGroup` 去掉 `cdpClients[0]` fallback,无 clientId 不分组
|
|
175
|
+
- `buildGroupName` 使用稳定 `CDP-<hash>` 而非 `CDP #<index>`(index 随 client 断连变化)
|
|
176
|
+
|
|
177
|
+
## [2.1.0] - 2025-05-11
|
|
178
|
+
|
|
179
|
+
### Added
|
|
180
|
+
- `preExistingTabIds` tracking: tabs opened by user BEFORE CDP connect are preserved on disconnect
|
|
181
|
+
- `CDPUtils.buildGroupName` / `getGroupBaseName` / `findGroupByName` / `getGroupColorForClient` — unified group helpers
|
|
182
|
+
- E2E test: `test-tab-group-isolation.js` — single client group + user tab survival
|
|
183
|
+
- E2E test: `test-multi-client-group-isolation.js` — multi-client group isolation + user tab survival
|
|
184
|
+
|
|
185
|
+
### Fixed
|
|
186
|
+
- **User tabs no longer closed on CDP disconnect**: `emitAutoAttachForExistingTargets` marks pre-existing tabs instead of grouping them; disconnect only closes grouped (CDP-created) tabs
|
|
187
|
+
- **Unified tab group naming**: `CDP #1 (N)` instead of inconsistent `CDP-<hash>` / `CDP-Automation` / `CDP-<timestamp>`
|
|
188
|
+
- **Unified group lookup**: all 3 files (`special.js`, `background.js`, `websocket.js`) now use `CDPUtils.findGroupByName` (prefix match)
|
|
189
|
+
- **Removed dead `popup.html` / `popup.js`** (no `default_popup` in manifest)
|
|
190
|
+
|
|
191
|
+
## [2.0.0] - 2025-05-11
|
|
192
|
+
|
|
193
|
+
### Added
|
|
194
|
+
- Landing page at `docs/index.html` with dark/light mode, feature showcase, CLI reference, and comparison table
|
|
195
|
+
- `CHANGELOG.md` for version history tracking
|
|
196
|
+
- `engines` field in package.json (Node.js >= 16)
|
|
197
|
+
- `repository`, `homepage`, `bugs` fields in package.json
|
|
198
|
+
- Expanded npm keywords for better discoverability
|
|
199
|
+
|
|
200
|
+
### Changed
|
|
201
|
+
- **Breaking**: package.json `description` now in English for npm discoverability
|
|
202
|
+
- Moved 47 root-level test/debug scripts to `tests/scratch/` — cleaner repository
|
|
203
|
+
- Extracted duplicate `String.prototype.hashCode` from `background.js` and `special.js` into shared `utils/helpers.js`
|
|
204
|
+
- Removed dead `popup.html` and `popup.js` (unused since manifest has no `default_popup`)
|
|
205
|
+
|
|
206
|
+
## [1.7.0] - 2025-05-10
|
|
207
|
+
|
|
208
|
+
### Added
|
|
209
|
+
- `/json/version` returns Chrome-compatible format (Browser, User-Agent, V8-Version, webSocketDebuggerUrl)
|
|
210
|
+
- `/json/list` includes `devtoolsFrontendUrl` and `faviconUrl` fields
|
|
211
|
+
- agent-browser `--cdp` compatibility via standard Chrome DevTools Protocol endpoints
|
|
212
|
+
- `emitAutoAttachForExistingTargets` now attaches unattached pages — fixes `pages()` returning empty
|
|
213
|
+
- CLI `--version` reads from package.json instead of hardcoded value
|
|
214
|
+
|
|
215
|
+
### Fixed
|
|
216
|
+
- Duplicate target events: `handleDebuggerEvent` in `debugger.js` now filters `Target.*` events
|
|
217
|
+
- CLI `update` command: skips when already latest version, uses `fs.readFileSync` (not require cache), added timeouts
|
|
218
|
+
|
|
219
|
+
## [1.6.1] - 2025-05-09
|
|
220
|
+
|
|
221
|
+
### Fixed
|
|
222
|
+
- Auto-mute: `muteTabIfNeeded()` called in ALL attach paths
|
|
223
|
+
|
|
224
|
+
### Added
|
|
225
|
+
- Custom CDP command `Tab.getMuteStatus` for verifying mute state from tests
|
|
226
|
+
|
|
227
|
+
## [1.6.0] - 2025-05-09
|
|
228
|
+
|
|
229
|
+
### Added
|
|
230
|
+
- `--auto-restart` flag: proxy detects plugin disconnect + client connect → auto-launches Chrome
|
|
231
|
+
- One-command `cdp-tunnel start`: auto-detects Chrome state, launches or guides user
|
|
232
|
+
- Auto-mute tabs when attached by automation
|
|
233
|
+
- Dynamic tab group names `CDP-{clientId:8} ({N})` with auto-collapse
|
|
234
|
+
|
|
235
|
+
## [1.5.1] - 2025-05-08
|
|
236
|
+
|
|
237
|
+
### Fixed
|
|
238
|
+
- `Browser.close` forwarded to extension (not intercepted by proxy)
|
|
239
|
+
|
|
240
|
+
## [1.5.0] - 2025-05-08
|
|
241
|
+
|
|
242
|
+
### Added
|
|
243
|
+
- Multi-client CDP isolation: Target.getTargets filtered by clientId
|
|
244
|
+
- Target events routed to owning client only
|
|
245
|
+
- `closeGroupById` closes tabs FIRST then detaches debugger
|
|
246
|
+
|
|
247
|
+
## [1.3.1] - 2025-05-07
|
|
248
|
+
|
|
249
|
+
### Added
|
|
250
|
+
- CLI `diagnose` command: 6-step health check (proxy, HTTP, extension, targets, Chrome, Playwright)
|
|
251
|
+
|
|
252
|
+
## [1.3.0] - 2025-05-07
|
|
253
|
+
|
|
254
|
+
### Added
|
|
255
|
+
- CLI help with usage examples
|
|
256
|
+
- `--watchdog` crash recovery mode
|
|
257
|
+
|
|
258
|
+
## [1.0.0] - 2025-05-05
|
|
259
|
+
|
|
260
|
+
### Added
|
|
261
|
+
- Initial release
|
|
262
|
+
- Chrome Extension CDP Proxy via `chrome.debugger` API
|
|
263
|
+
- WebSocket endpoint at `localhost:9221`
|
|
264
|
+
- Playwright and Puppeteer compatibility
|
|
265
|
+
- Configuration page with live dashboard
|
|
266
|
+
- E2E test suite with CI/CD pipeline
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# 多浏览器实例服务
|
|
2
|
+
|
|
3
|
+
把 cdp-tunnel 包装成一个"中转站"服务:一台服务器同时管理多个远程浏览器,每个浏览器用独立的 API Key 标识,互不可见。
|
|
4
|
+
|
|
5
|
+
## 简介
|
|
6
|
+
|
|
7
|
+
典型场景:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
服务方(你) 用户A / 用户B / ...
|
|
11
|
+
───────────── ──────────────
|
|
12
|
+
┌─────────────────────────┐ ┌──────────────┐
|
|
13
|
+
│ cdp-tunnel proxy │◀──wss───扩展 │ 用户A 浏览器 │
|
|
14
|
+
│ (公网 wss://...) │◀──wss───扩展 │ 用户B 浏览器 │
|
|
15
|
+
│ + key 鉴权 │ └──────────────┘
|
|
16
|
+
│ + 按 key 隔离 │
|
|
17
|
+
└─────────┬───────────────┘
|
|
18
|
+
│
|
|
19
|
+
▼ Playwright/CDP connectOverCDP
|
|
20
|
+
服务方用 clientUrl 接管浏览器
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- 一 key = 一浏览器实例(不同 key 的浏览器互不可见)
|
|
24
|
+
- 标准协议:客户端用 Playwright `connectOverCDP` 即可,无需学新 API
|
|
25
|
+
- 服务端发现:`/json/browsers` 列出所有在线浏览器
|
|
26
|
+
- 容错:某个浏览器断开,只影响对应 key,其他 key 不受影响
|
|
27
|
+
|
|
28
|
+
## 前置条件
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# 1. 安装 cdp-tunnel
|
|
32
|
+
npm install -g cdp-tunnel
|
|
33
|
+
|
|
34
|
+
# 2. 准备 Chromium(macOS 默认路径 /Applications/Chromium.app)
|
|
35
|
+
# 或通过 CHROME_PATH 环境变量指定
|
|
36
|
+
|
|
37
|
+
# 3. 进入项目目录(开发模式下)
|
|
38
|
+
cd cdp-tunnel2
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 快速开始
|
|
42
|
+
|
|
43
|
+
直接运行示例脚本,会自动完成全部流程:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
NO_PROXY=localhost,127.0.0.1,::1 node examples/multi-browser-service.js
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
预期输出:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
=== 多浏览器实例服务示例 ===
|
|
53
|
+
|
|
54
|
+
[SERVICE] 启动 cdp-tunnel proxy on port 29xxx...
|
|
55
|
+
[SERVICE] Proxy 就绪
|
|
56
|
+
[SERVICE] 用户A 的 Key: cdp_xxxx...
|
|
57
|
+
[SERVICE] 用户B 的 Key: cdp_xxxx...
|
|
58
|
+
[BROWSER] 启动用户A 的 Chrome...
|
|
59
|
+
[BROWSER] 启动用户B 的 Chrome...
|
|
60
|
+
[SERVICE] 发现 2 个在线浏览器
|
|
61
|
+
[CDP] 用户A 的浏览器打开百度...
|
|
62
|
+
[CDP] 用户A 百度 title: "百度一下,你就知道"
|
|
63
|
+
[CDP] 用户B 的浏览器打开 example.com...
|
|
64
|
+
[CDP] 用户B example.com title: "Example Domain"
|
|
65
|
+
[VERIFY] 用户A 看到的页面: ["https://www.baidu.com/"]
|
|
66
|
+
[VERIFY] 用户B 看到的页面: ["https://example.com/"]
|
|
67
|
+
|
|
68
|
+
=== 结果 ===
|
|
69
|
+
用户A 打开百度: ✅ (百度一下,你就知道)
|
|
70
|
+
用户B 打开 example: ✅ (Example Domain)
|
|
71
|
+
A 看不到 B 的页面: ✅
|
|
72
|
+
B 看不到 A 的页面: ✅
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 流程说明
|
|
76
|
+
|
|
77
|
+
示例脚本 [examples/multi-browser-service.js](multi-browser-service.js) 完整演示了以下 7 步:
|
|
78
|
+
|
|
79
|
+
### Step 1 — 启动 proxy(强制鉴权)
|
|
80
|
+
|
|
81
|
+
```javascript
|
|
82
|
+
const { spawn } = require('child_process');
|
|
83
|
+
const PROXY_PATH = require.resolve('cdp-tunnel/server/proxy-server.js');
|
|
84
|
+
|
|
85
|
+
const proxyProc = spawn('node', [PROXY_PATH], {
|
|
86
|
+
env: {
|
|
87
|
+
...process.env,
|
|
88
|
+
PORT: String(PORT),
|
|
89
|
+
REQUIRE_AUTH: 'true', // 强制 key 鉴权
|
|
90
|
+
POOL_SIZE: '3', // 端口池隔离
|
|
91
|
+
LOG_LEVEL: 'warn'
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Step 2 — 为每个用户创建 API Key
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
node server/saas/key-manager.js create 用户A
|
|
100
|
+
# 输出:
|
|
101
|
+
# Key: cdp_aaaa1111...
|
|
102
|
+
# 扩展连接地址: ws://your-server:PORT/plugin?key=cdp_aaaa1111...
|
|
103
|
+
# CDP 客户端地址: ws://your-server:PORT/client?key=cdp_aaaa1111...
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**一 key = 一浏览器实例槽位**。创建后把 pluginUrl 给用户填进扩展。
|
|
107
|
+
|
|
108
|
+
### Step 3 — 用户装扩展 + 配置 key
|
|
109
|
+
|
|
110
|
+
用户在自己电脑上:
|
|
111
|
+
1. `chrome://extensions/` → 开发者模式 → 加载 `extension-new/` 目录
|
|
112
|
+
2. 点扩展图标 → 配置页 → 添加连接 → 填入服务方给的 pluginUrl
|
|
113
|
+
3. 点"连接"——扩展通过 wss 连上服务方的 proxy
|
|
114
|
+
|
|
115
|
+
### Step 4 — 服务端发现浏览器
|
|
116
|
+
|
|
117
|
+
```javascript
|
|
118
|
+
const browsers = await httpGet(PORT, '/json/browsers');
|
|
119
|
+
// 返回所有在线浏览器列表
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Step 5 — 用 CDP 接管浏览器
|
|
123
|
+
|
|
124
|
+
```javascript
|
|
125
|
+
// 标准 Playwright 接入
|
|
126
|
+
const { chromium } = require('playwright');
|
|
127
|
+
const browser = await chromium.connectOverCDP(
|
|
128
|
+
`ws://your-server:${PORT}/client?key=${KEY_A}`
|
|
129
|
+
);
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
或用裸 CDP WebSocket(示例脚本采用的方式):
|
|
133
|
+
|
|
134
|
+
```javascript
|
|
135
|
+
const WebSocket = require('ws');
|
|
136
|
+
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/client?key=${KEY_A}`);
|
|
137
|
+
ws.on('open', () => {
|
|
138
|
+
ws.send(JSON.stringify({ id: 1, method: 'Target.createTarget', params: { url: 'about:blank' } }));
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Step 6 — 分别操作各浏览器
|
|
143
|
+
|
|
144
|
+
```javascript
|
|
145
|
+
// 用户A 打开百度
|
|
146
|
+
const { sessionId: sessA } = await createPageAndNavigate(clientA, 'https://www.baidu.com');
|
|
147
|
+
const titleA = await clientA.send('Runtime.evaluate', { expression: 'document.title' }, sessA);
|
|
148
|
+
|
|
149
|
+
// 用户B 打开 example.com
|
|
150
|
+
const { sessionId: sessB } = await createPageAndNavigate(clientB, 'https://example.com');
|
|
151
|
+
const titleB = await clientB.send('Runtime.evaluate', { expression: 'document.title' }, sessB);
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Step 7 — 验证隔离
|
|
155
|
+
|
|
156
|
+
```javascript
|
|
157
|
+
const tgA = await clientA.send('Target.getTargets');
|
|
158
|
+
const tgB = await clientB.send('Target.getTargets');
|
|
159
|
+
// keyA 只看到自己创建的百度,看不到 example.com
|
|
160
|
+
// keyB 只看到自己创建的 example.com,看不到百度
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## 验证点(测试覆盖)
|
|
164
|
+
|
|
165
|
+
自动化测试 [tests/e2e/test-multi-browser.js](../tests/e2e/test-multi-browser.js) 覆盖以下 11 个检查点,每个都是可断言的硬性指标:
|
|
166
|
+
|
|
167
|
+
| # | 检查点 | 说明 |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| 1 | 服务端发现 2 个浏览器 | `/json/browsers` 返回数组长度 ≥ 2 |
|
|
170
|
+
| 2 | keyA 打开百度 | createTarget + navigate + get title 非空 |
|
|
171
|
+
| 3 | keyB 打开 example.com | 同上 |
|
|
172
|
+
| 4 | keyA 看到自己打开的百度 | `Target.getTargets` 包含 baidu URL |
|
|
173
|
+
| 5 | keyA 看不到 keyB 的 example.com | `Target.getTargets` 不包含 example.com |
|
|
174
|
+
| 6 | keyB 看到自己打开的 example.com | `Target.getTargets` 包含 example.com URL |
|
|
175
|
+
| 7 | keyB 看不到 keyA 的百度 | `Target.getTargets` 不包含 baidu |
|
|
176
|
+
| 8 | 并发操作:keyA 得到 MARK-A | Promise.all 并发注入,结果正确 |
|
|
177
|
+
| 9 | 并发操作:keyB 得到 MARK-B | 同上 |
|
|
178
|
+
| 10 | ChromeA 断开后 keyA 被拒 | 新 client 连 `/client?key=KEY_A` 被 close |
|
|
179
|
+
| 11 | ChromeA 断开后 keyB 不受影响 | keyB 仍能 createTarget + evaluate |
|
|
180
|
+
|
|
181
|
+
## 发版前必跑
|
|
182
|
+
|
|
183
|
+
**每次发版前必须跑一遍,确保多浏览器场景未被破坏。**
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
# 单独跑多浏览器测试
|
|
187
|
+
NO_PROXY=localhost,127.0.0.1,::1 node tests/e2e/test-multi-browser.js
|
|
188
|
+
|
|
189
|
+
# 期望结果:11 passed, 0 failed
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
该测试已纳入 `run-all.js` 的 core tier:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
# 跑 core 测试套件(包含本测试)
|
|
196
|
+
SKIP_EXTENDED=1 npm run test:e2e
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## 关键设计
|
|
200
|
+
|
|
201
|
+
### 为什么用独立扩展副本
|
|
202
|
+
|
|
203
|
+
两个 Chrome 不能共享同一个 `extension-new/` 目录,因为每个扩展的 `config.js` 要注入不同的 key。示例采用的方式是:
|
|
204
|
+
|
|
205
|
+
```javascript
|
|
206
|
+
// 复制扩展到临时目录
|
|
207
|
+
fs.cpSync(EXTENSION_PATH, tmpExt, { recursive: true });
|
|
208
|
+
// 改 config.js 注入 key
|
|
209
|
+
fs.writeFileSync(configPath, configOriginal.replace(
|
|
210
|
+
/WS_URL:\s*'[^']*'/,
|
|
211
|
+
`WS_URL: 'ws://127.0.0.1:${port}/plugin?key=${key}'`
|
|
212
|
+
));
|
|
213
|
+
// 启动 Chrome 加载这个副本
|
|
214
|
+
spawn(CHROME_PATH, [`--load-extension=${tmpExt}`, ...]);
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### 为什么用 127.0.0.1 而不是 localhost
|
|
218
|
+
|
|
219
|
+
Node.js 25+ 在 macOS 上 `localhost` 会优先解析到 IPv6(`::1`),而 proxy 监听的是 `0.0.0.0`。虽然 `0.0.0.0` 理论上同时覆盖 IPv4/IPv6,但 IPv6 的连接在某些环境下不稳定。统一用 `127.0.0.1` 避免歧义。
|
|
220
|
+
|
|
221
|
+
### 为什么用 CDP WebSocket 而不是 Playwright
|
|
222
|
+
|
|
223
|
+
示例脚本用裸 CDP WebSocket(`ws` 库)而不是 Playwright `connectOverCDP`,因为:
|
|
224
|
+
- 更底层、更可控、不依赖 Playwright 版本
|
|
225
|
+
- 测试更稳定(Playwright 的 `contexts()` 在 connectOverCDP 场景下行为不一致)
|
|
226
|
+
|
|
227
|
+
实际生产中用 Playwright `connectOverCDP(clientUrl)` 完全可以,参考 [test-default-page.js](../tests/e2e/test-default-page.js) 的用法。
|
|
228
|
+
|
|
229
|
+
## 相关文件
|
|
230
|
+
|
|
231
|
+
- [examples/multi-browser-service.js](multi-browser-service.js) — 可直接运行的示例
|
|
232
|
+
- [tests/e2e/test-multi-browser.js](../tests/e2e/test-multi-browser.js) — 自动化测试(11 checks)
|
|
233
|
+
- [server/saas/key-manager.js](../server/saas/key-manager.js) — Key 管理 CLI
|
|
234
|
+
- [docs/DEPLOY-CLOUDFLARE.md](../docs/DEPLOY-CLOUDFLARE.md) — 公网部署指南
|
|
235
|
+
- [index.js](../index.js) — SDK 入口(`require('cdp-tunnel')`)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 多浏览器实例服务示例
|
|
5
|
+
*
|
|
6
|
+
* 场景:一台服务器同时管理多个远程浏览器
|
|
7
|
+
* - 服务方启动 cdp-tunnel proxy(REQUIRE_AUTH=true)
|
|
8
|
+
* - 为每个用户创建一个 API Key(一 key = 一浏览器实例)
|
|
9
|
+
* - 用户在自己电脑装扩展 + 填 pluginUrl
|
|
10
|
+
* - 服务方用 clientUrl 通过 Playwright/CDP 接管浏览器
|
|
11
|
+
*
|
|
12
|
+
* 运行:node examples/multi-browser-service.js
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { spawn, execSync } = require('child_process');
|
|
16
|
+
const http = require('http');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const WebSocket = require('ws');
|
|
20
|
+
|
|
21
|
+
// === 配置 ===
|
|
22
|
+
const CHROME_PATH = process.env.CHROME_PATH || '/Applications/Chromium.app/Contents/MacOS/Chromium';
|
|
23
|
+
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
|
24
|
+
const EXTENSION_PATH = path.join(PROJECT_ROOT, 'extension-new');
|
|
25
|
+
const PROXY_PATH = path.join(PROJECT_ROOT, 'server/proxy-server.js');
|
|
26
|
+
const KEY_MGR = path.join(PROJECT_ROOT, 'server/saas/key-manager.js');
|
|
27
|
+
|
|
28
|
+
function log(tag, msg) {
|
|
29
|
+
console.log(`[${new Date().toISOString().slice(11, 19)}] [${tag}] ${msg}`);
|
|
30
|
+
}
|
|
31
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
32
|
+
|
|
33
|
+
function httpGet(port, urlPath) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
http.get(`http://127.0.0.1:${port}${urlPath}`, res => {
|
|
36
|
+
let d = '';
|
|
37
|
+
res.on('data', c => d += c);
|
|
38
|
+
res.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
|
|
39
|
+
}).on('error', reject);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function waitForPort(port, maxWait = 15000) {
|
|
44
|
+
const start = Date.now();
|
|
45
|
+
while (Date.now() - start < maxWait) {
|
|
46
|
+
try { await httpGet(port, '/json/version'); return true; } catch { /* keep waiting */ }
|
|
47
|
+
await sleep(500);
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// === Key 管理 ===
|
|
53
|
+
function createKey(name) {
|
|
54
|
+
const out = execSync(`node ${KEY_MGR} create ${name}`, { encoding: 'utf8' });
|
|
55
|
+
const m = out.match(/Key:\s*(cdp_\w+)/);
|
|
56
|
+
return m ? m[1] : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// === CDP 客户端(轻量封装) ===
|
|
60
|
+
class CdpClient {
|
|
61
|
+
constructor(url) {
|
|
62
|
+
this.url = url;
|
|
63
|
+
this.ws = null;
|
|
64
|
+
this.idCounter = 1;
|
|
65
|
+
this.pending = new Map();
|
|
66
|
+
}
|
|
67
|
+
async connect() {
|
|
68
|
+
this.ws = new WebSocket(this.url);
|
|
69
|
+
await new Promise((r, e) => { this.ws.on('open', r); this.ws.on('error', e); });
|
|
70
|
+
this.ws.on('message', (data) => {
|
|
71
|
+
const msg = JSON.parse(data.toString());
|
|
72
|
+
if (msg.id && this.pending.has(msg.id)) {
|
|
73
|
+
const { resolve, reject } = this.pending.get(msg.id);
|
|
74
|
+
this.pending.delete(msg.id);
|
|
75
|
+
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
send(method, params = {}, sessionId) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const id = this.idCounter++;
|
|
83
|
+
this.pending.set(id, { resolve, reject });
|
|
84
|
+
setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`Timeout: ${method}`)); } }, 30000);
|
|
85
|
+
const msg = { id, method, params };
|
|
86
|
+
if (sessionId) msg.sessionId = sessionId;
|
|
87
|
+
this.ws.send(JSON.stringify(msg));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
close() { try { this.ws.close(); } catch {} }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// === 浏览器操作辅助 ===
|
|
94
|
+
async function createPageAndNavigate(client, url) {
|
|
95
|
+
const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
|
|
96
|
+
const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true });
|
|
97
|
+
await client.send('Page.enable', {}, sessionId);
|
|
98
|
+
await client.send('Page.navigate', { url }, sessionId);
|
|
99
|
+
for (let i = 0; i < 30; i++) {
|
|
100
|
+
try {
|
|
101
|
+
const result = await client.send('Runtime.evaluate', { expression: 'document.readyState' }, sessionId);
|
|
102
|
+
if (result?.result?.value === 'complete') break;
|
|
103
|
+
} catch {}
|
|
104
|
+
await sleep(500);
|
|
105
|
+
}
|
|
106
|
+
return { targetId, sessionId };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// === Chrome 启动(带扩展 + key 预设) ===
|
|
110
|
+
function launchChrome({ key, port, tag }) {
|
|
111
|
+
// 复制扩展到临时目录,注入 key
|
|
112
|
+
const tmpExt = `/tmp/cdp-ext-${tag}-${Date.now()}`;
|
|
113
|
+
fs.cpSync(EXTENSION_PATH, tmpExt, { recursive: true });
|
|
114
|
+
const configPath = path.join(tmpExt, 'utils', 'config.js');
|
|
115
|
+
const configOriginal = fs.readFileSync(configPath, 'utf8');
|
|
116
|
+
fs.writeFileSync(configPath, configOriginal.replace(
|
|
117
|
+
/WS_URL:\s*'[^']*'/, `WS_URL: 'ws://127.0.0.1:${port}/plugin?key=${key}'`
|
|
118
|
+
));
|
|
119
|
+
|
|
120
|
+
const profile = `/tmp/cdp-profile-${tag}-${Date.now()}`;
|
|
121
|
+
const proc = spawn(CHROME_PATH, [
|
|
122
|
+
'--headless=new',
|
|
123
|
+
`--user-data-dir=${profile}`,
|
|
124
|
+
`--load-extension=${tmpExt}`,
|
|
125
|
+
'--no-first-run', '--no-default-browser-check',
|
|
126
|
+
'--disable-features=DialMediaRouteProvider', '--no-sandbox',
|
|
127
|
+
'about:blank'
|
|
128
|
+
], { detached: true, stdio: 'ignore' });
|
|
129
|
+
proc._profile = profile;
|
|
130
|
+
proc._tmpExt = tmpExt;
|
|
131
|
+
return proc;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function killChrome(proc) {
|
|
135
|
+
if (!proc) return;
|
|
136
|
+
try { process.kill(-proc.pid); } catch {}
|
|
137
|
+
if (proc._profile) { try { fs.rmSync(proc._profile, { recursive: true, force: true }); } catch {} }
|
|
138
|
+
if (proc._tmpExt) { try { fs.rmSync(proc._tmpExt, { recursive: true, force: true }); } catch {} }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ============================================================
|
|
142
|
+
// 主流程:模拟多浏览器实例服务
|
|
143
|
+
// ============================================================
|
|
144
|
+
(async () => {
|
|
145
|
+
const PORT = 29700 + Math.floor(Math.random() * 500);
|
|
146
|
+
let proxyProc, chromeA, chromeB, clientA, clientB;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
console.log('\n=== 多浏览器实例服务示例 ===\n');
|
|
150
|
+
|
|
151
|
+
// Step 1: 启动 proxy(强制鉴权)
|
|
152
|
+
log('SERVICE', `启动 cdp-tunnel proxy on port ${PORT}...`);
|
|
153
|
+
proxyProc = spawn('node', [PROXY_PATH], {
|
|
154
|
+
env: {
|
|
155
|
+
...process.env,
|
|
156
|
+
PORT: String(PORT),
|
|
157
|
+
POOL_SIZE: '3',
|
|
158
|
+
POOL_START: String(PORT + 1),
|
|
159
|
+
TAKEOVER_PORT: String(PORT + 10),
|
|
160
|
+
POOL_TAKEOVER_PORT: String(PORT + 10),
|
|
161
|
+
REQUIRE_AUTH: 'true',
|
|
162
|
+
LOG_LEVEL: 'warn'
|
|
163
|
+
},
|
|
164
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
165
|
+
});
|
|
166
|
+
proxyProc.stdout.on('data', () => {});
|
|
167
|
+
proxyProc.stderr.on('data', () => {});
|
|
168
|
+
if (!await waitForPort(PORT)) throw new Error('Proxy 启动失败');
|
|
169
|
+
log('SERVICE', 'Proxy 就绪');
|
|
170
|
+
|
|
171
|
+
// Step 2: 为两个用户创建 Key
|
|
172
|
+
const KEY_A = createKey('用户A');
|
|
173
|
+
const KEY_B = createKey('用户B');
|
|
174
|
+
log('SERVICE', `用户A 的 Key: ${KEY_A?.slice(0, 20)}...`);
|
|
175
|
+
log('SERVICE', `用户B 的 Key: ${KEY_B?.slice(0, 20)}...`);
|
|
176
|
+
log('SERVICE', `用户A 的扩展连接地址: ws://your-server:${PORT}/plugin?key=${KEY_A}`);
|
|
177
|
+
log('SERVICE', `用户B 的扩展连接地址: ws://your-server:${PORT}/plugin?key=${KEY_B}`);
|
|
178
|
+
|
|
179
|
+
// Step 3: 启动两个 Chrome(模拟用户装扩展 + 配置 key)
|
|
180
|
+
log('BROWSER', '启动用户A 的 Chrome...');
|
|
181
|
+
chromeA = launchChrome({ key: KEY_A, port: PORT, tag: 'A' });
|
|
182
|
+
log('BROWSER', '启动用户B 的 Chrome...');
|
|
183
|
+
chromeB = launchChrome({ key: KEY_B, port: PORT, tag: 'B' });
|
|
184
|
+
|
|
185
|
+
// Step 4: 等待两个浏览器上线(服务端发现)
|
|
186
|
+
log('SERVICE', '等待浏览器连接...');
|
|
187
|
+
for (let i = 0; i < 60; i++) {
|
|
188
|
+
try {
|
|
189
|
+
const browsers = await httpGet(PORT, '/json/browsers');
|
|
190
|
+
if (Array.isArray(browsers) && browsers.length >= 2) {
|
|
191
|
+
log('SERVICE', `发现 ${browsers.length} 个在线浏览器`);
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
} catch {}
|
|
195
|
+
await sleep(2000);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Step 5: 用 CDP 接管两个浏览器
|
|
199
|
+
log('CDP', '连接用户A 的浏览器...');
|
|
200
|
+
clientA = await new CdpClient(`ws://127.0.0.1:${PORT}/client?key=${KEY_A}`).connect();
|
|
201
|
+
log('CDP', '连接用户B 的浏览器...');
|
|
202
|
+
clientB = await new CdpClient(`ws://127.0.0.1:${PORT}/client?key=${KEY_B}`).connect();
|
|
203
|
+
|
|
204
|
+
// Step 6: 分别操作两个浏览器
|
|
205
|
+
log('CDP', '用户A 的浏览器打开百度...');
|
|
206
|
+
const { sessionId: sessA } = await createPageAndNavigate(clientA, 'https://www.baidu.com');
|
|
207
|
+
const titleA = (await clientA.send('Runtime.evaluate', { expression: 'document.title' }, sessA))?.result?.value;
|
|
208
|
+
log('CDP', `用户A 百度 title: "${titleA}"`);
|
|
209
|
+
|
|
210
|
+
log('CDP', '用户B 的浏览器打开 example.com...');
|
|
211
|
+
const { sessionId: sessB } = await createPageAndNavigate(clientB, 'https://example.com');
|
|
212
|
+
const titleB = (await clientB.send('Runtime.evaluate', { expression: 'document.title' }, sessB))?.result?.value;
|
|
213
|
+
log('CDP', `用户B example.com title: "${titleB}"`);
|
|
214
|
+
|
|
215
|
+
// Step 7: 验证隔离
|
|
216
|
+
const tgA = await clientA.send('Target.getTargets');
|
|
217
|
+
const tgB = await clientB.send('Target.getTargets');
|
|
218
|
+
const urlsA = (tgA.targetInfos || []).filter(t => t.type === 'page').map(t => t.url);
|
|
219
|
+
const urlsB = (tgB.targetInfos || []).filter(t => t.type === 'page').map(t => t.url);
|
|
220
|
+
log('VERIFY', `用户A 看到的页面: ${JSON.stringify(urlsA)}`);
|
|
221
|
+
log('VERIFY', `用户B 看到的页面: ${JSON.stringify(urlsB)}`);
|
|
222
|
+
|
|
223
|
+
console.log('\n=== 结果 ===');
|
|
224
|
+
console.log(` 用户A 打开百度: ${titleA === '百度一下,你就知道' ? '✅' : '❌'} (${titleA})`);
|
|
225
|
+
console.log(` 用户B 打开 example: ${titleB === 'Example Domain' ? '✅' : '❌'} (${titleB})`);
|
|
226
|
+
console.log(` A 看不到 B 的页面: ${!urlsA.some(u => u.includes('example.com')) ? '✅' : '❌'}`);
|
|
227
|
+
console.log(` B 看不到 A 的页面: ${!urlsB.some(u => u.includes('baidu')) ? '✅' : '❌'}`);
|
|
228
|
+
console.log('');
|
|
229
|
+
|
|
230
|
+
} finally {
|
|
231
|
+
// 清理
|
|
232
|
+
try { if (clientA) clientA.close(); } catch {}
|
|
233
|
+
try { if (clientB) clientB.close(); } catch {}
|
|
234
|
+
try { if (chromeA) killChrome(chromeA); } catch {}
|
|
235
|
+
try { if (chromeB) killChrome(chromeB); } catch {}
|
|
236
|
+
try { proxyProc && proxyProc.kill('SIGTERM'); } catch {}
|
|
237
|
+
}
|
|
238
|
+
})();
|
package/index.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* cdp-tunnel — 把 Chrome 扩展(chrome.debugger)桥接成标准 CDP WebSocket 服务
|
|
5
|
+
*
|
|
6
|
+
* 典型用法(本地一键启动):
|
|
7
|
+
* const cdp = require('cdp-tunnel');
|
|
8
|
+
* const { port } = await cdp.startServer({ requireAuth: true });
|
|
9
|
+
* const { pluginUrl, clientUrl } = cdp.createApiKey('张三');
|
|
10
|
+
* // 把 pluginUrl 给用户填进扩展,用 clientUrl 让 Playwright connectOverCDP 接管
|
|
11
|
+
*
|
|
12
|
+
* 对外服务化部署:
|
|
13
|
+
* const { port } = await cdp.startServer({
|
|
14
|
+
* requireAuth: true,
|
|
15
|
+
* strictVersion: true,
|
|
16
|
+
* adminToken: 'your-secret',
|
|
17
|
+
* externalHost: 'cdn.example.com:8443' // 生成给用户的地址用这个 host
|
|
18
|
+
* });
|
|
19
|
+
* const key = cdp.createApiKey('用户A', { host: 'cdn.example.com:8443' });
|
|
20
|
+
*
|
|
21
|
+
* 客户端不需要本 SDK —— 标准 Playwright/Puppeteer 用 connectOverCDP(clientUrl) 即可。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const { spawn } = require('child_process');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const http = require('http');
|
|
27
|
+
const auth = require('./server/saas/auth');
|
|
28
|
+
const { PortPoolManager } = require('./server/modules/port-pool');
|
|
29
|
+
const { CONFIG } = require('./server/modules/config');
|
|
30
|
+
|
|
31
|
+
const PROXY_SERVER_PATH = path.join(__dirname, 'server', 'proxy-server.js');
|
|
32
|
+
const BUILTIN_USER = { id: 'builtin-admin', email: 'admin@local', displayName: 'Admin' };
|
|
33
|
+
|
|
34
|
+
// ====== 内置用户(绕过注册系统,SDK 直接管理 key) ======
|
|
35
|
+
|
|
36
|
+
function ensureBuiltinUser() {
|
|
37
|
+
const db = require('./server/saas/db');
|
|
38
|
+
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(BUILTIN_USER.id);
|
|
39
|
+
if (!existing) {
|
|
40
|
+
db.prepare('INSERT INTO users (id, email, password_hash, display_name) VALUES (?, ?, ?, ?)')
|
|
41
|
+
.run(BUILTIN_USER.id, BUILTIN_USER.email, 'builtin-no-login', BUILTIN_USER.displayName);
|
|
42
|
+
}
|
|
43
|
+
return BUILTIN_USER;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ====== Key 管理(一 key = 一浏览器实例) ======
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 创建 API Key(= 一个浏览器实例槽位)
|
|
50
|
+
* @param {string} [name] 实例名称,用于辨识
|
|
51
|
+
* @param {object} [opts] { host } 自定义对外地址,如 'cdn.example.com:8443'。不传则用 EXTERNAL_HOST 环境变量或 localhost:PORT
|
|
52
|
+
* @returns {{ keyId, key, name, pluginUrl, clientUrl }}
|
|
53
|
+
*/
|
|
54
|
+
function createApiKey(name, opts = {}) {
|
|
55
|
+
ensureBuiltinUser();
|
|
56
|
+
const keyName = name || ('browser-' + Date.now().toString(36));
|
|
57
|
+
const keyInfo = auth.createApiKey(BUILTIN_USER.id, keyName);
|
|
58
|
+
const host = opts.host || process.env.EXTERNAL_HOST || `localhost:${CONFIG.PORT}`;
|
|
59
|
+
const proto = host.startsWith('localhost') || host.startsWith('127.') ? 'ws' : 'wss';
|
|
60
|
+
return {
|
|
61
|
+
keyId: keyInfo.id,
|
|
62
|
+
key: keyInfo.key,
|
|
63
|
+
name: keyName,
|
|
64
|
+
pluginUrl: `${proto}://${host}/plugin?key=${keyInfo.key}`,
|
|
65
|
+
clientUrl: `${proto}://${host}/client?key=${keyInfo.key}`
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 列出所有 Key
|
|
71
|
+
* @returns {Array<{id, name, active, created_at, last_used_at}>}
|
|
72
|
+
*/
|
|
73
|
+
function listApiKeys() {
|
|
74
|
+
ensureBuiltinUser();
|
|
75
|
+
return auth.listApiKeys(BUILTIN_USER.id);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 吊销 Key
|
|
80
|
+
* @param {string} keyId
|
|
81
|
+
*/
|
|
82
|
+
function revokeApiKey(keyId) {
|
|
83
|
+
ensureBuiltinUser();
|
|
84
|
+
return auth.revokeApiKey(keyId, BUILTIN_USER.id);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 校验 Key(返回 userId/keyId/keyName 或 null)
|
|
89
|
+
* @param {string} key
|
|
90
|
+
*/
|
|
91
|
+
function validateApiKey(key) {
|
|
92
|
+
return auth.validateApiKey(key);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ====== 服务启动 / 停止 ======
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 编程式启动 proxy server(子进程方式,不阻塞当前进程)
|
|
99
|
+
* @param {object} [opts]
|
|
100
|
+
* @param {number} [opts.port] 端口,默认 9221
|
|
101
|
+
* @param {boolean} [opts.requireAuth] 强制 key 鉴权
|
|
102
|
+
* @param {boolean} [opts.strictVersion] 扩展版本严格校验
|
|
103
|
+
* @param {string} [opts.adminToken] 管理后台 token
|
|
104
|
+
* @param {string} [opts.externalHost] 对外 host(生成给用户的地址用)
|
|
105
|
+
* @param {function} [opts.onLog] 日志回调 (msg) => void
|
|
106
|
+
* @param {object} [opts.env] 额外环境变量
|
|
107
|
+
* @returns {Promise<{ process, port, stop }>}
|
|
108
|
+
*/
|
|
109
|
+
function startServer(opts = {}) {
|
|
110
|
+
const port = opts.port || CONFIG.PORT;
|
|
111
|
+
const env = {
|
|
112
|
+
...process.env,
|
|
113
|
+
PORT: String(port),
|
|
114
|
+
REQUIRE_AUTH: opts.requireAuth ? 'true' : (process.env.REQUIRE_AUTH || ''),
|
|
115
|
+
STRICT_VERSION: opts.strictVersion ? 'true' : (process.env.STRICT_VERSION || ''),
|
|
116
|
+
ADMIN_TOKEN: opts.adminToken || process.env.ADMIN_TOKEN || '',
|
|
117
|
+
EXTERNAL_HOST: opts.externalHost || process.env.EXTERNAL_HOST || '',
|
|
118
|
+
...(opts.env || {})
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const child = spawn('node', [PROXY_SERVER_PATH], {
|
|
123
|
+
env,
|
|
124
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
let started = false;
|
|
128
|
+
const timeout = setTimeout(() => {
|
|
129
|
+
if (!started) {
|
|
130
|
+
child.kill('SIGTERM');
|
|
131
|
+
reject(new Error(`Server failed to start within 15s on port ${port}`));
|
|
132
|
+
}
|
|
133
|
+
}, 15000);
|
|
134
|
+
|
|
135
|
+
const onLine = (buf) => {
|
|
136
|
+
const msg = buf.toString();
|
|
137
|
+
if (opts.onLog) opts.onLog(msg);
|
|
138
|
+
if (!started && msg.includes(`Server started on port ${port}`)) {
|
|
139
|
+
started = true;
|
|
140
|
+
clearTimeout(timeout);
|
|
141
|
+
resolve({
|
|
142
|
+
process: child,
|
|
143
|
+
port,
|
|
144
|
+
stop: () => {
|
|
145
|
+
child.kill('SIGTERM');
|
|
146
|
+
return new Promise(r => child.on('exit', r));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
child.stdout.on('data', onLine);
|
|
152
|
+
child.stderr.on('data', onLine);
|
|
153
|
+
|
|
154
|
+
child.on('error', (err) => {
|
|
155
|
+
clearTimeout(timeout);
|
|
156
|
+
reject(err);
|
|
157
|
+
});
|
|
158
|
+
child.on('exit', (code) => {
|
|
159
|
+
clearTimeout(timeout);
|
|
160
|
+
if (!started) reject(new Error(`Server exited with code ${code} before starting`));
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 健康检查:探测某端口的 proxy 是否就绪
|
|
167
|
+
* @param {number} [port] 默认 9221
|
|
168
|
+
* @param {number} [timeoutMs] 默认 5000
|
|
169
|
+
*/
|
|
170
|
+
function isServerReady(port = CONFIG.PORT, timeoutMs = 5000) {
|
|
171
|
+
const start = Date.now();
|
|
172
|
+
return new Promise(async (resolve) => {
|
|
173
|
+
while (Date.now() - start < timeoutMs) {
|
|
174
|
+
try {
|
|
175
|
+
await new Promise((res, rej) => {
|
|
176
|
+
const req = http.get(`http://localhost:${port}/json/version`, (r) => {
|
|
177
|
+
r.on('data', () => {});
|
|
178
|
+
r.on('end', res);
|
|
179
|
+
});
|
|
180
|
+
req.on('error', rej);
|
|
181
|
+
req.setTimeout(500, () => req.destroy(new Error('timeout')));
|
|
182
|
+
});
|
|
183
|
+
resolve(true);
|
|
184
|
+
return;
|
|
185
|
+
} catch {
|
|
186
|
+
await new Promise(r => setTimeout(r, 300));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
resolve(false);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
// 服务启动 / 停止
|
|
195
|
+
startServer,
|
|
196
|
+
isServerReady,
|
|
197
|
+
|
|
198
|
+
// Key 管理(一 key = 一浏览器实例)
|
|
199
|
+
createApiKey,
|
|
200
|
+
listApiKeys,
|
|
201
|
+
revokeApiKey,
|
|
202
|
+
validateApiKey,
|
|
203
|
+
|
|
204
|
+
// 高级用法
|
|
205
|
+
PortPoolManager,
|
|
206
|
+
CONFIG,
|
|
207
|
+
|
|
208
|
+
// 完整 auth 模块(createUser / JWT / 密码等)
|
|
209
|
+
auth
|
|
210
|
+
};
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdp-tunnel",
|
|
3
|
-
"version": "3.6.
|
|
3
|
+
"version": "3.6.4",
|
|
4
4
|
"description": "Bridge Chrome's debugger API to WebSocket — control your existing browser with Playwright/Puppeteer via CDP",
|
|
5
|
-
"main": "
|
|
5
|
+
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cdp-tunnel": "./cli/index.js"
|
|
8
8
|
},
|
|
@@ -43,10 +43,13 @@
|
|
|
43
43
|
"node": ">=16.0.0"
|
|
44
44
|
},
|
|
45
45
|
"files": [
|
|
46
|
+
"index.js",
|
|
46
47
|
"server/",
|
|
47
48
|
"cli/",
|
|
48
49
|
"extension-new/",
|
|
50
|
+
"examples/",
|
|
49
51
|
"README.md",
|
|
52
|
+
"CHANGELOG.md",
|
|
50
53
|
"LICENSE"
|
|
51
54
|
],
|
|
52
55
|
"dependencies": {
|
|
@@ -261,22 +261,12 @@ class PortPoolManager {
|
|
|
261
261
|
return;
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
-
// SW 冷启动 warmup:第一个 client 连接时创建一个临时 tab 再关闭
|
|
265
|
-
if (!session.warmedUp) {
|
|
266
|
-
session.warmedUp = true;
|
|
267
|
-
const warmupId = `pool${session.portIndex}_internal_warmup`;
|
|
268
|
-
pluginWs.send(JSON.stringify({
|
|
269
|
-
id: warmupId,
|
|
270
|
-
method: 'Target.createTarget',
|
|
271
|
-
params: { url: 'about:blank' },
|
|
272
|
-
__portIndex: session.portIndex,
|
|
273
|
-
__clientId: `pool_${session.port}`
|
|
274
|
-
}));
|
|
275
|
-
// warmup 响应回来后在 handlePluginMessage 里关闭这个 tab
|
|
276
|
-
session.pendingWarmupClose = true;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
264
|
// 通知扩展有 client 连接(带端口号作为分组标识)
|
|
265
|
+
// 对齐原生 CDP:不创建任何隐式的 about:blank 页面。
|
|
266
|
+
// 原生 chrome --remote-debugging-port 在 client 连接后不创建任何 target,
|
|
267
|
+
// 只在 connectOverCDP 的 setAutoAttach 期间发现已有 target。
|
|
268
|
+
// (移除旧版 warmup 逻辑:它用 Target.createTarget 创建临时 about:blank 再关闭,
|
|
269
|
+
// 但时间窗口内 Playwright 的 setAutoAttach 会发现它,导致用户看到多余空白页。)
|
|
280
270
|
// clientId 固定为 pool_{port},让扩展为每个端口建一个独立分组
|
|
281
271
|
// __groupName 带 key 名称,让扩展用 key 名称命名 Chrome 分组(一眼看出是谁的浏览器)
|
|
282
272
|
const poolClientId = `pool_${session.port}`;
|
|
@@ -360,23 +350,8 @@ class PortPoolManager {
|
|
|
360
350
|
|
|
361
351
|
// 1. 响应消息:id 以 pool 开头(排除事件——事件有 method 字段)
|
|
362
352
|
if (msg.id && typeof msg.id === 'string' && msg.id.startsWith('pool') && !msg.method) {
|
|
363
|
-
//
|
|
353
|
+
// 内部命令响应(_ensureVisible 的 bringToFront/waitVis 等):丢弃,不路由给 client
|
|
364
354
|
if (msg.id.includes('_internal')) {
|
|
365
|
-
// warmup createTarget 响应——关掉 warmup tab
|
|
366
|
-
const m = msg.id.match(/^pool(\d+)_/);
|
|
367
|
-
if (m && msg.id.includes('_warmup') && msg.result && msg.result.targetId) {
|
|
368
|
-
const pidx = parseInt(m[1]);
|
|
369
|
-
const sess = this.portSessions[pidx];
|
|
370
|
-
const warmupTid = msg.result.targetId;
|
|
371
|
-
const closeId = `pool${pidx}_internal_warmup_close_${Date.now()}`;
|
|
372
|
-
pluginWs.send(JSON.stringify({
|
|
373
|
-
id: closeId,
|
|
374
|
-
method: 'Target.closeTarget',
|
|
375
|
-
params: { targetId: warmupTid },
|
|
376
|
-
__portIndex: pidx,
|
|
377
|
-
__clientId: `pool_${sess.port}`
|
|
378
|
-
}));
|
|
379
|
-
}
|
|
380
355
|
return true;
|
|
381
356
|
}
|
|
382
357
|
const match = msg.id.match(/^pool(\d+)_(.+)$/);
|