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 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
+ })();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "CDP Bridge",
4
- "version": "3.6.2",
4
+ "version": "3.6.4",
5
5
  "description": "Chrome DevTools Protocol Bridge for Playwright/Puppeteer automation",
6
6
  "permissions": [
7
7
  "debugger",
@@ -1,5 +1,5 @@
1
1
  var Config = {
2
- WS_URL: 'ws://localhost:51100/plugin',
2
+ WS_URL: 'ws://localhost:10108/plugin',
3
3
  RECONNECT_DELAY: 3000,
4
4
  DEBUGGER_VERSION: '1.3',
5
5
  HEARTBEAT_INTERVAL: 25000,
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.2",
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": "server/proxy-server.js",
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+)_(.+)$/);