cdp-tunnel 1.0.0

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 (46) hide show
  1. package/.github/workflows/publish.yml +92 -0
  2. package/.github/workflows/release-assets.yml +50 -0
  3. package/LICENSE +81 -0
  4. package/PUBLISH.md +65 -0
  5. package/README.md +228 -0
  6. package/cli/guide.html +753 -0
  7. package/cli/icon.svg +13 -0
  8. package/cli/icon128.png +0 -0
  9. package/cli/index.js +357 -0
  10. package/docs/README_CN.md +204 -0
  11. package/docs/config-page-screenshot.png +0 -0
  12. package/extension-new/background.js +294 -0
  13. package/extension-new/cdp/handler/forward.js +44 -0
  14. package/extension-new/cdp/handler/local.js +233 -0
  15. package/extension-new/cdp/handler/special.js +442 -0
  16. package/extension-new/cdp/index.js +104 -0
  17. package/extension-new/cdp/response.js +49 -0
  18. package/extension-new/config-page-preview.html +769 -0
  19. package/extension-new/config-page.js +318 -0
  20. package/extension-new/core/debugger.js +310 -0
  21. package/extension-new/core/state.js +384 -0
  22. package/extension-new/core/websocket.js +326 -0
  23. package/extension-new/features/automation-badge.js +113 -0
  24. package/extension-new/features/screencast.js +221 -0
  25. package/extension-new/icons/icon128.png +0 -0
  26. package/extension-new/icons/icon16.png +0 -0
  27. package/extension-new/icons/icon48.png +0 -0
  28. package/extension-new/manifest.json +39 -0
  29. package/extension-new/popup.html +72 -0
  30. package/extension-new/popup.js +34 -0
  31. package/extension-new/utils/config.js +20 -0
  32. package/extension-new/utils/diagnostics.js +560 -0
  33. package/extension-new/utils/helpers.js +25 -0
  34. package/extension-new/utils/logger.js +64 -0
  35. package/package.json +42 -0
  36. package/server/modules/config.js +28 -0
  37. package/server/modules/logger.js +197 -0
  38. package/server/proxy-server.js +1431 -0
  39. package/tests/playwright-demo.js +45 -0
  40. package/tests/playwright-interactive.js +261 -0
  41. package/tests/playwright-multi-demo.js +60 -0
  42. package/tests/playwright-multi.js +85 -0
  43. package/tests/playwright-single.js +41 -0
  44. package/tests/screenshot-config.js +35 -0
  45. package/tests/test-client.js +89 -0
  46. package/tests/test-multi-client.js +129 -0
@@ -0,0 +1,1431 @@
1
+ /**
2
+ * WebSocket 代理服务器
3
+ * 用于连接 Chrome 扩展 (CDP) 和 Playwright/Puppeteer 客户端
4
+ *
5
+ * 功能:
6
+ * - 单端口 9221,通过路径区分连接类型
7
+ * - /plugin 路径: 接收 Chrome 扩展的 CDP 连接
8
+ * - /client 路径: 接收 Playwright/Puppeteer 客户端连接
9
+ * - 双向透传消息
10
+ */
11
+
12
+ const http = require('http');
13
+ const WebSocket = require('ws');
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const os = require('os');
17
+ const { CONFIG, BROWSER_ID, shouldLog } = require('./modules/config');
18
+ const { logCDP, logEvent, clearLog, logStatus, logConnectionEvent, flushAllLogs } = require('./modules/logger');
19
+
20
+ const PORT = CONFIG.PORT;
21
+ const CONFIG_DIR = path.join(os.homedir(), '.cdp-tunnel');
22
+ const EXTENSION_STATE_FILE = path.join(CONFIG_DIR, 'extension-state.json');
23
+
24
+ function updateExtensionState(connected) {
25
+ try {
26
+ if (!fs.existsSync(CONFIG_DIR)) {
27
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
28
+ }
29
+ fs.writeFileSync(EXTENSION_STATE_FILE, JSON.stringify({
30
+ connected: connected,
31
+ lastSeen: Date.now()
32
+ }));
33
+ } catch (e) {}
34
+ }
35
+
36
+ clearLog();
37
+
38
+ const wss = new WebSocket.Server({ noServer: true });
39
+ const server = http.createServer((req, res) => handleHttpRequest(req, res));
40
+
41
+ const pluginConnections = new Set();
42
+ const clientConnections = new Set();
43
+
44
+ const connectionPairs = new Map();
45
+ const clientById = new Map();
46
+ const sessionToClientId = new Map();
47
+ const pendingAttachRequests = new Map();
48
+ const clientIdToPlugin = new Map();
49
+ const globalRequestIdMap = new Map();
50
+ const targetIdToClientId = new Map();
51
+ const pendingAttachedEvents = new Map();
52
+ const browserContextToClientId = new Map();
53
+ let globalRequestIdCounter = 0;
54
+
55
+ let cachedTargets = [];
56
+ let lastTargetsUpdate = 0;
57
+
58
+ console.log('='.repeat(60));
59
+ console.log(' WebSocket CDP Proxy Server');
60
+ console.log('='.repeat(60));
61
+ console.log(` Server started on port ${PORT}`);
62
+ console.log(` - Plugin path: ws://localhost:${PORT}/plugin`);
63
+ console.log(` - Client path: ws://localhost:${PORT}/client`);
64
+ console.log(` - CDP endpoint: http://localhost:${PORT}`);
65
+ console.log('='.repeat(60));
66
+
67
+ /**
68
+ * 获取请求的 Host
69
+ */
70
+ function getHost(req) {
71
+ return req.headers.host || `localhost:${PORT}`;
72
+ }
73
+
74
+ /**
75
+ * 生成 WebSocket 调试地址
76
+ */
77
+ function buildWebSocketDebuggerUrl(req) {
78
+ return `ws://${getHost(req)}/devtools/browser/${BROWSER_ID}`;
79
+ }
80
+
81
+ function buildTargetWebSocketUrl(req, targetId) {
82
+ return `ws://${getHost(req)}/devtools/page/${targetId}`;
83
+ }
84
+
85
+ async function requestTargetsFromPlugin() {
86
+ const now = Date.now();
87
+ if (now - lastTargetsUpdate < CONFIG.TARGETS_CACHE_TTL && cachedTargets.length > 0) {
88
+ return cachedTargets;
89
+ }
90
+
91
+ const plugin = pluginConnections.values().next().value;
92
+ if (!plugin || plugin.readyState !== WebSocket.OPEN) {
93
+ return cachedTargets;
94
+ }
95
+
96
+ return new Promise((resolve) => {
97
+ const requestId = `targets_${Date.now()}`;
98
+ const timeout = setTimeout(() => {
99
+ resolve(cachedTargets);
100
+ }, CONFIG.TARGETS_REQUEST_TIMEOUT);
101
+
102
+ const handler = (data) => {
103
+ try {
104
+ const msg = JSON.parse(data.toString());
105
+ if (msg.id === requestId && msg.result?.targetInfos) {
106
+ clearTimeout(timeout);
107
+ plugin.off('message', handler);
108
+ cachedTargets = msg.result.targetInfos;
109
+ lastTargetsUpdate = now;
110
+ resolve(cachedTargets);
111
+ }
112
+ } catch (e) {}
113
+ };
114
+
115
+ plugin.on('message', handler);
116
+ plugin.send(JSON.stringify({ id: requestId, method: 'Target.getTargets' }));
117
+ });
118
+ }
119
+
120
+ /**
121
+ * 处理 HTTP 请求
122
+ */
123
+ async function handleHttpRequest(req, res) {
124
+ const url = new URL(req.url, `http://localhost:${PORT}`);
125
+
126
+ if (url.pathname === '/json/version' || url.pathname === '/json/version/') {
127
+ const payload = {
128
+ Browser: 'CDP Bridge',
129
+ 'Protocol-Version': '1.3',
130
+ 'User-Agent': 'Chrome',
131
+ 'V8-Version': '',
132
+ webSocketDebuggerUrl: buildWebSocketDebuggerUrl(req)
133
+ };
134
+ res.writeHead(200, { 'Content-Type': 'application/json' });
135
+ res.end(JSON.stringify(payload));
136
+ return;
137
+ }
138
+
139
+ if (url.pathname === '/json' || url.pathname === '/json/' ||
140
+ url.pathname === '/json/list' || url.pathname === '/json/list/') {
141
+ const targets = await requestTargetsFromPlugin();
142
+ const targetList = targets
143
+ .filter(t => {
144
+ if (t.type !== 'page') return false;
145
+ const url = t.url || '';
146
+ if (url.startsWith('chrome://') ||
147
+ url.startsWith('chrome-extension://') ||
148
+ url.startsWith('devtools://') ||
149
+ url.startsWith('about:blank') ||
150
+ url.startsWith('edge://')) {
151
+ return false;
152
+ }
153
+ return true;
154
+ })
155
+ .map(t => ({
156
+ description: '',
157
+ devtoolsFrontendUrl: '',
158
+ id: t.targetId,
159
+ title: t.title || '',
160
+ type: t.type,
161
+ url: t.url || '',
162
+ webSocketDebuggerUrl: buildTargetWebSocketUrl(req, t.targetId)
163
+ }));
164
+ res.writeHead(200, { 'Content-Type': 'application/json' });
165
+ res.end(JSON.stringify(targetList));
166
+ return;
167
+ }
168
+
169
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
170
+ res.end('Not Found');
171
+ }
172
+
173
+ /**
174
+ * 根据 upgrade 请求的 URL 路径区分连接类型
175
+ */
176
+ server.on('upgrade', (req, socket, head) => {
177
+ const url = new URL(req.url, `http://localhost:${PORT}`);
178
+ const path = url.pathname;
179
+ const isPlugin = path === '/plugin';
180
+ const isClient = path === '/client' ||
181
+ path.startsWith('/client-') ||
182
+ path.startsWith('/devtools/browser/') ||
183
+ path.startsWith('/devtools/page/');
184
+
185
+ if (!isPlugin && !isClient) {
186
+ socket.destroy();
187
+ return;
188
+ }
189
+
190
+ wss.handleUpgrade(req, socket, head, (ws) => {
191
+ wss.emit('connection', ws, req);
192
+ });
193
+ });
194
+
195
+ wss.on('connection', (ws, req) => {
196
+ const url = new URL(req.url, `http://localhost:${PORT}`);
197
+ const path = url.pathname;
198
+
199
+ const clientInfo = {
200
+ ip: req.socket.remoteAddress,
201
+ port: req.socket.remotePort
202
+ };
203
+
204
+ if (path === '/plugin') {
205
+ handlePluginConnection(ws, clientInfo);
206
+ } else if (path === '/client' || path.startsWith('/client-') || path.startsWith('/devtools/browser/')) {
207
+ const customClientId = path.startsWith('/client-') ? path.replace('/client-', '') : null;
208
+ handleClientConnection(ws, clientInfo, customClientId);
209
+ } else if (path.startsWith('/devtools/page/')) {
210
+ const targetId = path.replace('/devtools/page/', '');
211
+ handlePageConnection(ws, clientInfo, targetId);
212
+ } else {
213
+ console.log(`[REJECTED] Unknown path: ${path} from ${clientInfo.ip}:${clientInfo.port}`);
214
+ ws.close(1008, 'Invalid path. Use /plugin or /client');
215
+ }
216
+ });
217
+
218
+ /**
219
+ * 处理 Chrome 扩展连接
220
+ */
221
+ function handlePluginConnection(ws, clientInfo) {
222
+ const id = generateId('plugin');
223
+
224
+ if (pluginConnections.size > 0) {
225
+ const toRemove = [];
226
+ pluginConnections.forEach(oldWs => {
227
+ if (oldWs !== ws) {
228
+ if (oldWs.readyState === WebSocket.OPEN) {
229
+ oldWs.send(JSON.stringify({ type: 'server-restart' }));
230
+ oldWs.close(1001, 'Server restarted');
231
+ }
232
+ toRemove.push(oldWs);
233
+ }
234
+ });
235
+ toRemove.forEach(oldWs => {
236
+ pluginConnections.delete(oldWs);
237
+ if (shouldLog('info')) {
238
+ console.log(`[PLUGIN] Removed old connection: ${oldWs.id}`);
239
+ }
240
+ });
241
+ }
242
+
243
+ sessionToClientId.clear();
244
+ pendingAttachRequests.clear();
245
+ connectionPairs.clear();
246
+ clientConnections.forEach(clientWs => {
247
+ clientWs.pairedPlugin = null;
248
+ });
249
+
250
+ pluginConnections.add(ws);
251
+
252
+ const pluginType = 'plugin';
253
+
254
+ if (shouldLog('info')) {
255
+ console.log(`\n[PLUGIN CONNECTED] ID: ${id}`);
256
+ console.log(` - Remote: ${clientInfo.ip}:${clientInfo.port}`);
257
+ console.log(` - Total plugin connections: ${pluginConnections.size}`);
258
+ }
259
+
260
+ logConnectionEvent('PLUGIN_CONNECTED', {
261
+ id,
262
+ ip: clientInfo.ip,
263
+ port: clientInfo.port,
264
+ totalPlugins: pluginConnections.size,
265
+ totalClients: clientConnections.size
266
+ });
267
+
268
+ updateExtensionState(true);
269
+
270
+ // 如果有待配对的客户端,自动配对
271
+ if (clientConnections.size > 0) {
272
+ for (const clientWs of clientConnections) {
273
+ if (!connectionPairs.has(clientWs.id)) {
274
+ connectionPairs.set(clientWs.id, ws);
275
+ ws.pairedClientId = clientWs.id;
276
+ clientWs.pairedPlugin = ws;
277
+ if (shouldLog('info')) {
278
+ console.log(` - Paired with client: ${clientWs.id}`);
279
+ }
280
+ logConnectionEvent('PLUGIN_PAIRED', { pluginId: id, clientId: clientWs.id });
281
+ break;
282
+ }
283
+ }
284
+ }
285
+
286
+ ws.id = id;
287
+ ws.isAlive = true;
288
+ ws.pluginType = pluginType;
289
+
290
+ // 发送当前客户端列表给新连接的插件
291
+ const clients = [];
292
+ clientConnections.forEach((client) => {
293
+ clients.push({
294
+ id: client.id,
295
+ connectedAt: client.connectedAt,
296
+ lastActivity: client.lastActivityTime
297
+ });
298
+ });
299
+ ws.send(JSON.stringify({
300
+ type: 'client-list',
301
+ clients: clients
302
+ }));
303
+
304
+ // 心跳检测
305
+ ws.on('pong', () => {
306
+ ws.isAlive = true;
307
+ logConnectionEvent('HEARTBEAT_PONG', { type: 'plugin', id: ws.id });
308
+ });
309
+
310
+ // 消息转发: Plugin -> Client
311
+ ws.on('message', (data) => {
312
+ const messageSize = data.length;
313
+ let messagePreview;
314
+ let parsed;
315
+ try {
316
+ parsed = JSON.parse(data);
317
+ messagePreview = parsed.method || parsed.id || 'response';
318
+ } catch {
319
+ parsed = null;
320
+ messagePreview = `binary (${messageSize} bytes)`;
321
+ }
322
+
323
+ if (shouldLog('debug')) {
324
+ console.log(`[PLUGIN -> CLIENT] ${id}: ${messagePreview}`);
325
+ }
326
+
327
+ // 处理 keepalive 消息
328
+ if (parsed && parsed.type === 'keepalive') {
329
+ ws.isAlive = true;
330
+ logConnectionEvent('KEEPALIVE_RECEIVED', { type: 'plugin', id: ws.id });
331
+ return;
332
+ }
333
+
334
+ // 记录所有 PLUGIN -> CLIENT 消息到日志文件
335
+ logCDP('PLUGIN -> CLIENT', data.toString().substring(0, CONFIG.LOG_MESSAGE_PREVIEW_LENGTH), parsed?.sessionId, ws.pluginType);
336
+
337
+ // 调试:打印所有收到的消息
338
+ console.log(`[PLUGIN MSG] id=${parsed?.id} method=${parsed?.method || 'none'} type=${parsed?.type || 'none'} sessionId=${parsed?.sessionId?.substring(0,8) || 'none'}`);
339
+
340
+ // 处理 type: 'event' 消息(来自 background.js 的 screencast 等事件)
341
+ if (parsed && parsed.type === 'event' && parsed.method) {
342
+ logCDP('DEBUG', `Converting type:event message: ${parsed.method}`, parsed?.sessionId);
343
+
344
+ // 处理 Target.attachedToTarget 事件,建立 sessionId -> clientId 映射
345
+ if (parsed.method === 'Target.attachedToTarget') {
346
+ const targetId = parsed.params?.targetInfo?.targetId;
347
+ const sessionId = parsed.params?.sessionId;
348
+
349
+ console.log(`[ATTACHED EVENT (type:event)] targetId=${targetId} sessionId=${sessionId?.substring(0,8)}`);
350
+
351
+ // 查找 targetId 对应的 clientId
352
+ const clientId = targetIdToClientId.get(targetId);
353
+ if (clientId && sessionId) {
354
+ sessionToClientId.set(sessionId, clientId);
355
+ console.log(`[SESSION MAPPED from event] sessionId=${sessionId.substring(0,8)} -> clientId=${clientId} (targetId=${targetId})`);
356
+ targetIdToClientId.delete(targetId);
357
+
358
+ // 转换为 CDP 格式并发送给对应的客户端
359
+ const cdpMsg = {
360
+ method: parsed.method,
361
+ params: parsed.params
362
+ };
363
+
364
+ const clientWs = clientById.get(clientId);
365
+ if (clientWs && clientWs.readyState === WebSocket.OPEN) {
366
+ clientWs.send(JSON.stringify(cdpMsg));
367
+ console.log(`[ATTACHED EVENT] Sent to client: ${clientId}`);
368
+ }
369
+ return;
370
+ } else if (targetId && sessionId) {
371
+ // targetId 还没有映射,缓存事件等待 Target.createTarget 响应
372
+ pendingAttachedEvents.set(targetId, { sessionId, parsed, data });
373
+ console.log(`[ATTACHED EVENT] Cached for targetId=${targetId}, waiting for createTarget response`);
374
+ return;
375
+ }
376
+ }
377
+
378
+ const cdpMsg = {
379
+ method: parsed.method,
380
+ params: parsed.params
381
+ };
382
+ if (parsed.sessionId) {
383
+ cdpMsg.sessionId = parsed.sessionId;
384
+ }
385
+ const cdpData = JSON.stringify(cdpMsg);
386
+
387
+ // 发送给配对的 client
388
+ if (ws.pairedClientId) {
389
+ const clientWs = clientById.get(ws.pairedClientId);
390
+ if (safeSend(clientWs, cdpData, 'client')) {
391
+ logCDP('DEBUG', `Sent converted event to client: ${parsed.method}`, parsed?.sessionId);
392
+ return;
393
+ }
394
+ }
395
+ // 广播给所有 client
396
+ broadcastToClients(cdpData, ws);
397
+ return;
398
+ }
399
+
400
+ if (parsed && parsed.id === undefined && !parsed.method) {
401
+ logCDP('DEBUG', `BLOCKED message (no id, no method): ${JSON.stringify(parsed).substring(0, 100)}`, null);
402
+ return;
403
+ }
404
+
405
+ // 路由消息到正确的客户端
406
+ // 优先级:请求ID路由 > Target.attachedToTarget事件 > sessionId路由 > 广播到所有客户端
407
+
408
+ // 1. 请求 ID 路由:响应对应特定客户端的请求(优先级最高)
409
+ // 响应消息有 id,可能也有 sessionId,但应该用 id 路由
410
+ if (parsed && parsed.id !== undefined) {
411
+ const globalId = parsed.id;
412
+ const mapping = globalRequestIdMap.get(globalId);
413
+ console.log(`[RESPONSE DEBUG] globalId=${globalId} hasMapping=${!!mapping} sessionId=${parsed.sessionId?.substring(0,8) || 'none'} method=${parsed.method || 'response'}`);
414
+ if (mapping) {
415
+ const clientWs = clientById.get(mapping.clientId);
416
+ if (clientWs && clientWs.readyState === WebSocket.OPEN) {
417
+ // 如果是 Target.createBrowserContext 响应,记录 browserContextId -> clientId 映射
418
+ if (mapping.isCreateBrowserContext && parsed.result?.browserContextId) {
419
+ const browserContextId = parsed.result.browserContextId;
420
+ browserContextToClientId.set(browserContextId, mapping.clientId);
421
+ console.log(`[BROWSER CONTEXT MAPPED] browserContextId=${browserContextId} -> clientId=${mapping.clientId}`);
422
+ }
423
+
424
+ // 如果是 Target.createTarget 响应,先发送缓存的 Target.attachedToTarget 事件
425
+ // 然后再发送响应
426
+ if (mapping.isCreateTarget && parsed.result?.targetId) {
427
+ const targetId = parsed.result.targetId;
428
+ targetIdToClientId.set(targetId, mapping.clientId);
429
+ console.log(`[TARGET MAPPED] targetId=${targetId} -> clientId=${mapping.clientId}`);
430
+
431
+ // 检查是否有缓存的 Target.attachedToTarget 事件
432
+ const cachedEvent = pendingAttachedEvents.get(targetId);
433
+ if (cachedEvent) {
434
+ sessionToClientId.set(cachedEvent.sessionId, mapping.clientId);
435
+ console.log(`[SESSION MAPPED from cached] sessionId=${cachedEvent.sessionId.substring(0,8)} -> clientId=${mapping.clientId} (targetId=${targetId})`);
436
+ pendingAttachedEvents.delete(targetId);
437
+
438
+ // 先发送缓存的事件给客户端
439
+ // 注意:Target.attachedToTarget 事件必须发送给 root session(没有顶层 sessionId)
440
+ // sessionId 在 params 里面,不在消息顶层
441
+ const cdpMsg = {
442
+ method: cachedEvent.parsed.method,
443
+ params: cachedEvent.parsed.params
444
+ };
445
+ const msgStr = JSON.stringify(cdpMsg);
446
+ console.log(`[ATTACHED EVENT] Full message: ${msgStr}`);
447
+ clientWs.send(msgStr);
448
+ console.log(`[ATTACHED EVENT] Sent cached event to client: ${mapping.clientId}`);
449
+ }
450
+ }
451
+
452
+ // 然后发送响应给客户端
453
+ const originalId = mapping.originalId;
454
+ parsed.id = originalId;
455
+ // 如果请求有 sessionId,但响应没有,添加 sessionId
456
+ if (mapping.sessionId && !parsed.sessionId) {
457
+ parsed.sessionId = mapping.sessionId;
458
+ }
459
+ const responseStr = JSON.stringify(parsed);
460
+ console.log(`[SEND TO CLIENT] ${responseStr.substring(0, 300)}`);
461
+ clientWs.send(responseStr);
462
+ console.log(`[ROUTE] Response global=${globalId} -> original=${originalId} -> client=${mapping.clientId} sessionId=${parsed.sessionId?.substring(0,8) || 'none'}`);
463
+ }
464
+ globalRequestIdMap.delete(globalId);
465
+ } else {
466
+ console.log(`[WARN] No mapping for global requestId: ${globalId}`);
467
+ }
468
+ return;
469
+ }
470
+
471
+ // 2. sessionId 路由:消息属于特定 session(事件,没有 id)
472
+ if (parsed && parsed.sessionId) {
473
+ const targetClientId = sessionToClientId.get(parsed.sessionId);
474
+ console.log(`[SESSION ROUTE] sessionId=${parsed.sessionId?.substring(0,8)} -> clientId=${targetClientId || 'not found'}`);
475
+ if (targetClientId) {
476
+ const clientWs = clientById.get(targetClientId);
477
+ if (clientWs && clientWs.readyState === WebSocket.OPEN) {
478
+ clientWs.send(data);
479
+ logCDP('DEBUG', `FORWARDED to client: ${targetClientId} (sessionId route)`, parsed?.sessionId);
480
+ }
481
+ } else {
482
+ console.log(`[WARN] No clientId for sessionId: ${parsed.sessionId?.substring(0, 8)}`);
483
+ }
484
+ return;
485
+ }
486
+
487
+ // 3. 事件广播:无 id 和 sessionId 的消息(如 Target.targetCreated)
488
+ // 只广播特定类型的事件,避免干扰其他客户端
489
+ if (parsed && parsed.method) {
490
+ // 处理 Target.attachedToTarget 事件,建立 sessionId -> clientId 映射
491
+ if (parsed.method === 'Target.attachedToTarget') {
492
+ const targetId = parsed.params?.targetInfo?.targetId;
493
+ const sessionId = parsed.params?.sessionId;
494
+ const openerId = parsed.params?.targetInfo?.openerId;
495
+
496
+ // 查找 targetId 对应的 clientId
497
+ let clientId = targetIdToClientId.get(targetId);
498
+
499
+ // 如果没有直接映射,检查 openerId(window.open 打开的新 tab)
500
+ if (!clientId && openerId) {
501
+ // 查找 openerId 对应的 clientId
502
+ // openerId 可能是某个已知的 targetId
503
+ clientId = targetIdToClientId.get(openerId);
504
+ if (clientId) {
505
+ console.log(`[OPENER TRACKING] targetId=${targetId?.substring(0,8)} openerId=${openerId?.substring(0,8)} -> clientId=${clientId}`);
506
+ // 记录新 targetId 的映射
507
+ targetIdToClientId.set(targetId, clientId);
508
+ }
509
+ }
510
+
511
+ if (clientId && sessionId) {
512
+ sessionToClientId.set(sessionId, clientId);
513
+ console.log(`[SESSION MAPPED from event] sessionId=${sessionId.substring(0,8)} -> clientId=${clientId} (targetId=${targetId?.substring(0,8)})`);
514
+ targetIdToClientId.delete(targetId);
515
+
516
+ // 只发送给对应的客户端
517
+ const clientWs = clientById.get(clientId);
518
+ if (clientWs && clientWs.readyState === WebSocket.OPEN) {
519
+ clientWs.send(data);
520
+ }
521
+ return;
522
+ }
523
+ }
524
+
525
+ // 处理 Target.targetInfoChanged 事件
526
+ if (parsed.method === 'Target.targetInfoChanged') {
527
+ const targetId = parsed.params?.targetInfo?.targetId;
528
+ const openerId = parsed.params?.targetInfo?.openerId;
529
+ console.log(`[TARGET INFO CHANGED] targetId=${targetId?.substring(0,8)} openerId=${openerId?.substring(0, 8) || 'none'}`);
530
+ }
531
+
532
+ if (parsed.method === 'Target.targetCreated') {
533
+ const targetId = parsed.params?.targetInfo?.targetId;
534
+ const openerId = parsed.params?.targetInfo?.openerId;
535
+ const browserContextId = parsed.params?.targetInfo?.browserContextId;
536
+ const targetType = parsed.params?.targetInfo?.type;
537
+
538
+ console.log(`[TARGET CREATED] targetId=${targetId?.substring(0,8)} type=${targetType} openerId=${openerId?.substring(0, 8) || 'none'} browserContextId=${browserContextId?.substring(0, 8) || 'none'}`);
539
+
540
+ // 如果有 openerId,尝试找到对应的 clientId
541
+ if (openerId && targetId) {
542
+ const openerClientId = targetIdToClientId.get(openerId);
543
+ if (openerClientId) {
544
+ targetIdToClientId.set(targetId, openerClientId);
545
+ console.log(`[TARGET CREATED with opener] targetId=${targetId?.substring(0,8)} openerId=${openerId?.substring(0,8)} -> clientId=${openerClientId}`);
546
+ }
547
+ }
548
+
549
+ // 如果有 browserContextId,尝试找到对应的 clientId
550
+ // browserContextId 是通过 Target.createBrowserContext 创建的
551
+ if (browserContextId && targetId) {
552
+ const contextClientId = browserContextToClientId.get(browserContextId);
553
+ if (contextClientId && !targetIdToClientId.has(targetId)) {
554
+ targetIdToClientId.set(targetId, contextClientId);
555
+ console.log(`[TARGET CREATED in context] targetId=${targetId?.substring(0,8)} browserContextId=${browserContextId?.substring(0,8)} -> clientId=${contextClientId}`);
556
+ }
557
+ }
558
+
559
+ // Service Worker 处理:Service Worker 通常属于创建它的页面所在的客户端
560
+ // 通过 browserContextId 来判断归属
561
+ if (targetType === 'service_worker' && browserContextId && targetId) {
562
+ const contextClientId = browserContextToClientId.get(browserContextId);
563
+ if (contextClientId) {
564
+ targetIdToClientId.set(targetId, contextClientId);
565
+ console.log(`[SERVICE WORKER] targetId=${targetId?.substring(0,8)} -> clientId=${contextClientId}`);
566
+ }
567
+ }
568
+
569
+ // iframe (OOPIF) 处理:跨域 iframe 可能有独立的 target
570
+ // 通过 openerId 或 browserContextId 来判断归属
571
+ if (targetType === 'iframe' && targetId) {
572
+ // 优先使用 openerId
573
+ if (openerId) {
574
+ const openerClientId = targetIdToClientId.get(openerId);
575
+ if (openerClientId) {
576
+ targetIdToClientId.set(targetId, openerClientId);
577
+ console.log(`[IFRAME with opener] targetId=${targetId?.substring(0,8)} openerId=${openerId?.substring(0,8)} -> clientId=${openerClientId}`);
578
+ }
579
+ } else if (browserContextId) {
580
+ // 否则使用 browserContextId
581
+ const contextClientId = browserContextToClientId.get(browserContextId);
582
+ if (contextClientId) {
583
+ targetIdToClientId.set(targetId, contextClientId);
584
+ console.log(`[IFRAME in context] targetId=${targetId?.substring(0,8)} browserContextId=${browserContextId?.substring(0,8)} -> clientId=${contextClientId}`);
585
+ }
586
+ }
587
+ }
588
+ }
589
+
590
+ const broadcastMethods = [
591
+ 'Target.targetCreated',
592
+ 'Target.targetDestroyed',
593
+ 'Target.targetInfoChanged'
594
+ ];
595
+ if (broadcastMethods.includes(parsed.method)) {
596
+ for (const clientWs of clientConnections) {
597
+ if (clientWs.readyState === WebSocket.OPEN) {
598
+ clientWs.send(data);
599
+ }
600
+ }
601
+ }
602
+ }
603
+ });
604
+
605
+ // 连接关闭
606
+ ws.on('close', (code, reason) => {
607
+ pluginConnections.delete(ws);
608
+ if (shouldLog('info')) {
609
+ console.log(`\n[PLUGIN DISCONNECTED] ${id}`);
610
+ console.log(` - Code: ${code}, Reason: ${reason || 'none'}`);
611
+ console.log(` - Total plugin connections: ${pluginConnections.size}`);
612
+ }
613
+
614
+ logConnectionEvent('PLUGIN_DISCONNECTED', {
615
+ id,
616
+ code,
617
+ reason: reason?.toString() || 'none',
618
+ totalPlugins: pluginConnections.size,
619
+ totalClients: clientConnections.size
620
+ });
621
+
622
+ if (pluginConnections.size === 0) {
623
+ updateExtensionState(false);
624
+ }
625
+
626
+ // 清理配对关系并通知所有受影响的 Client
627
+ const affectedClients = [];
628
+ clientConnections.forEach(clientWs => {
629
+ if (clientWs.pairedPlugin === ws) {
630
+ // 清理 page 连接的事件监听器
631
+ if (clientWs.pluginMessageHandler) {
632
+ ws.off('message', clientWs.pluginMessageHandler);
633
+ clientWs.pluginMessageHandler = null;
634
+ }
635
+ clientWs.pairedPlugin = null;
636
+ affectedClients.push(clientWs.id);
637
+ if (clientWs.readyState === WebSocket.OPEN) {
638
+ clientWs.send(JSON.stringify({
639
+ type: 'plugin-disconnected',
640
+ message: 'Plugin connection lost'
641
+ }));
642
+ }
643
+ if (shouldLog('debug')) {
644
+ console.log(` - Cleared pairedPlugin for client: ${clientWs.id}`);
645
+ }
646
+ }
647
+ });
648
+
649
+ if (affectedClients.length > 0) {
650
+ logConnectionEvent('PLUGIN_DISCONNECT_AFFECTED_CLIENTS', { pluginId: id, affectedClients });
651
+ }
652
+
653
+ if (ws.pairedClientId) {
654
+ connectionPairs.delete(ws.pairedClientId);
655
+ }
656
+ });
657
+
658
+ // 错误处理
659
+ ws.on('error', (error) => {
660
+ console.error(`[PLUGIN ERROR] ${id}:`, error.message);
661
+
662
+ logConnectionEvent('PLUGIN_ERROR', {
663
+ id,
664
+ error: error.message,
665
+ totalPlugins: pluginConnections.size,
666
+ totalClients: clientConnections.size
667
+ });
668
+
669
+ pluginConnections.delete(ws);
670
+
671
+ clientConnections.forEach(clientWs => {
672
+ if (clientWs.pairedPlugin === ws) {
673
+ // 清理 page 连接的事件监听器
674
+ if (clientWs.pluginMessageHandler) {
675
+ ws.off('message', clientWs.pluginMessageHandler);
676
+ clientWs.pluginMessageHandler = null;
677
+ }
678
+ clientWs.pairedPlugin = null;
679
+ console.log(` - Cleared pairedPlugin for client: ${clientWs.id} due to error`);
680
+ }
681
+ });
682
+
683
+ if (ws.pairedClientId) {
684
+ connectionPairs.delete(ws.pairedClientId);
685
+ }
686
+ });
687
+
688
+ ws.send(JSON.stringify({
689
+ type: 'connected',
690
+ role: 'plugin',
691
+ id: id,
692
+ fresh: true,
693
+ timestamp: Date.now()
694
+ }));
695
+ }
696
+
697
+ /**
698
+ * 处理 CDP 客户端连接 (Playwright/Puppeteer)
699
+ */
700
+ function handleClientConnection(ws, clientInfo, customClientId = null) {
701
+ clientConnections.add(ws);
702
+ const id = customClientId || generateId('client');
703
+ if (shouldLog('info')) {
704
+ console.log(`\n[CLIENT CONNECTED] ID: ${id}${customClientId ? ' (custom)' : ''}`);
705
+ console.log(` - Remote: ${clientInfo.ip}:${clientInfo.port}`);
706
+ console.log(` - Total client connections: ${clientConnections.size}`);
707
+ }
708
+
709
+ logConnectionEvent('CLIENT_CONNECTED', {
710
+ id,
711
+ ip: clientInfo.ip,
712
+ port: clientInfo.port,
713
+ totalPlugins: pluginConnections.size,
714
+ totalClients: clientConnections.size
715
+ });
716
+
717
+ // 检查是否有可用的 plugin 连接
718
+ if (pluginConnections.size === 0) {
719
+ if (shouldLog('warn')) {
720
+ console.log(` - WARNING: No plugin connections available!`);
721
+ console.log(` - Please ensure Chrome extension is connected.`);
722
+ }
723
+ logConnectionEvent('CLIENT_NO_PLUGIN', { clientId: id });
724
+ } else {
725
+ // 多客户端模式: 所有客户端共享同一个 plugin
726
+ // 每个 clientId 对应不同的 tab
727
+ const pluginWs = pluginConnections.values().next().value;
728
+ if (pluginWs) {
729
+ connectionPairs.set(id, pluginWs);
730
+ ws.pairedPlugin = pluginWs;
731
+ clientIdToPlugin.set(id, pluginWs);
732
+
733
+ if (shouldLog('info')) {
734
+ console.log(` - Paired with plugin: ${pluginWs.id} (shared mode)`);
735
+ }
736
+
737
+ logConnectionEvent('CLIENT_PAIRED', { clientId: id, pluginId: pluginWs.id });
738
+
739
+ // 通知 Plugin 新客户端已连接
740
+ pluginWs.send(JSON.stringify({
741
+ type: 'client-connected',
742
+ clientId: id
743
+ }));
744
+
745
+ // 发送当前所有客户端列表
746
+ broadcastClientList();
747
+ }
748
+ }
749
+
750
+ ws.id = id;
751
+ ws.isAlive = true;
752
+ ws.cdpTrace = [];
753
+ ws.lastActivityTime = Date.now();
754
+ ws.connectedAt = Date.now();
755
+ clientById.set(id, ws);
756
+
757
+ // 心跳检测
758
+ ws.on('pong', () => {
759
+ ws.isAlive = true;
760
+ logConnectionEvent('HEARTBEAT_PONG', { type: 'client', id: ws.id });
761
+ });
762
+
763
+ // 消息转发: Client -> Plugin
764
+ ws.on('message', (data) => {
765
+ ws.lastActivityTime = Date.now();
766
+
767
+ const messageSize = data.length;
768
+ let messagePreview;
769
+ let parsed;
770
+ try {
771
+ parsed = JSON.parse(data);
772
+ messagePreview = parsed.method || parsed.id || 'response';
773
+ } catch {
774
+ parsed = null;
775
+ messagePreview = `binary (${messageSize} bytes)`;
776
+ }
777
+
778
+ if (shouldLog('debug')) {
779
+ console.log(`[CLIENT -> PLUGIN] ${id}: ${messagePreview}`);
780
+ }
781
+
782
+ // 记录到日志文件
783
+ logCDP('CLIENT -> PLUGIN', data.toString().substring(0, CONFIG.LOG_MESSAGE_PREVIEW_LENGTH), parsed?.sessionId);
784
+
785
+ // 为每个请求分配全局唯一 ID,避免多客户端 ID 冲突
786
+ let modifiedData = data;
787
+ if (parsed && parsed.id !== undefined) {
788
+ const originalId = parsed.id;
789
+ globalRequestIdCounter++;
790
+ const globalId = globalRequestIdCounter;
791
+
792
+ // 保存映射:全局ID -> {clientId, originalId, sessionId}
793
+ // 如果请求有 sessionId,也保存它,用于响应路由
794
+ globalRequestIdMap.set(globalId, {
795
+ clientId: id,
796
+ originalId: originalId,
797
+ sessionId: parsed.sessionId // 保存请求的 sessionId
798
+ });
799
+
800
+ // 修改请求ID为全局ID
801
+ parsed.id = globalId;
802
+ modifiedData = JSON.stringify(parsed);
803
+
804
+ console.log(`[REQUEST ID MAPPED] client=${id} original=${originalId} -> global=${globalId} sessionId=${parsed.sessionId?.substring(0,8) || 'none'}`);
805
+ }
806
+
807
+ // 记录 Target.attachToTarget 请求,用于后续建立 session -> clientId 映射
808
+ if (parsed && parsed.method === 'Target.attachToTarget' && parsed.id !== undefined) {
809
+ pendingAttachRequests.set(parsed.id, id);
810
+ console.log(`[PENDING ATTACH] Request id=${parsed.id} from client=${id}, pending size=${pendingAttachRequests.size}`);
811
+ }
812
+
813
+ // 记录 Target.createTarget 请求,用于后续建立 targetId -> clientId 映射
814
+ // 注意:此时 parsed.id 已经是 globalId,originalId 已经保存在 mapping 中
815
+ if (parsed && parsed.method === 'Target.createTarget' && parsed.id !== undefined) {
816
+ // 获取当前请求的映射(刚刚创建的)
817
+ const currentMapping = globalRequestIdMap.get(parsed.id);
818
+ if (currentMapping) {
819
+ // 标记为 createTarget 请求
820
+ currentMapping.isCreateTarget = true;
821
+ }
822
+ console.log(`[PENDING CREATE TARGET] Request id=${parsed.id} from client=${id}`);
823
+ }
824
+
825
+ // 记录 Target.createBrowserContext 请求,用于后续建立 browserContextId -> clientId 映射
826
+ if (parsed && parsed.method === 'Target.createBrowserContext' && parsed.id !== undefined) {
827
+ const currentMapping = globalRequestIdMap.get(parsed.id);
828
+ if (currentMapping) {
829
+ currentMapping.isCreateBrowserContext = true;
830
+ }
831
+ console.log(`[PENDING CREATE CONTEXT] Request id=${parsed.id} from client=${id}`);
832
+ }
833
+
834
+ // 拦截 Browser.close - 清理会话状态
835
+ if (parsed && parsed.method === 'Browser.close') {
836
+ if (shouldLog('info')) {
837
+ console.log(`\n[BROWSER CLOSE] Client ${id} requested Browser.close`);
838
+ }
839
+
840
+ // 清理该客户端的所有 session 映射
841
+ const sessionsToClean = [];
842
+ for (const [sessionId, clientId] of sessionToClientId.entries()) {
843
+ if (clientId === id) {
844
+ sessionsToClean.push(sessionId);
845
+ sessionToClientId.delete(sessionId);
846
+ }
847
+ }
848
+ if (shouldLog('info')) {
849
+ console.log(` - Cleaned ${sessionsToClean.length} sessions`);
850
+ }
851
+
852
+ // 通知扩展清理状态
853
+ if (ws.pairedPlugin && ws.pairedPlugin.readyState === WebSocket.OPEN) {
854
+ ws.pairedPlugin.send(JSON.stringify({
855
+ type: 'browser-close',
856
+ clientId: id,
857
+ sessions: sessionsToClean
858
+ }));
859
+ }
860
+
861
+ // 返回 mock 响应(包含 sessionId)
862
+ const response = { id: parsed.id, result: {} };
863
+ if (parsed.sessionId) {
864
+ response.sessionId = parsed.sessionId;
865
+ }
866
+ ws.send(JSON.stringify(response));
867
+ return;
868
+ }
869
+
870
+ if (parsed && parsed.method) {
871
+ ws.cdpTrace.push(parsed.method);
872
+ if (ws.cdpTrace.length > CONFIG.CDP_TRACE_MAX_LENGTH) {
873
+ ws.cdpTrace = ws.cdpTrace.slice(-CONFIG.CDP_TRACE_MAX_LENGTH);
874
+ }
875
+ if (shouldLog('debug')) {
876
+ console.log(`[CDP TRACE] ${id} -> ${parsed.method}`);
877
+ }
878
+ }
879
+
880
+ // 发送给配对的 plugin (或广播)
881
+ if (ws.pairedPlugin && ws.pairedPlugin.readyState === WebSocket.OPEN) {
882
+ console.log(`[SEND TO PLUGIN] id=${parsed?.id} method=${parsed?.method} sessionId=${parsed?.sessionId?.substring(0,8) || 'none'}`);
883
+ ws.pairedPlugin.send(modifiedData);
884
+ } else {
885
+ broadcastToPlugins(modifiedData, ws);
886
+ }
887
+ });
888
+
889
+ // 连接关闭
890
+ ws.on('close', async (code, reason) => {
891
+ // 记录断开事件到日志文件
892
+ logCDP('EVENT', `CLIENT DISCONNECTED id=${id} code=${code} reason=${reason.toString() || 'none'}`);
893
+
894
+ // 收集该 client 的所有 session
895
+ const sessionsToClean = [];
896
+ for (const [sessionId, clientId] of sessionToClientId.entries()) {
897
+ if (clientId === id) {
898
+ sessionsToClean.push(sessionId);
899
+ sessionToClientId.delete(sessionId);
900
+ }
901
+ }
902
+
903
+ clientConnections.delete(ws);
904
+ clientById.delete(id);
905
+ if (shouldLog('info')) {
906
+ console.log(`\n[CLIENT DISCONNECTED] ${id}`);
907
+ console.log(` - Code: ${code}, Reason: ${reason || 'none'}`);
908
+ console.log(` - Sessions to clean: ${sessionsToClean.length}`);
909
+ console.log(` - Total client connections: ${clientConnections.size}`);
910
+ }
911
+
912
+ logConnectionEvent('CLIENT_DISCONNECTED', {
913
+ id,
914
+ code,
915
+ reason: reason?.toString() || 'none',
916
+ sessionsCleaned: sessionsToClean.length,
917
+ totalPlugins: pluginConnections.size,
918
+ totalClients: clientConnections.size
919
+ });
920
+
921
+ if (ws.cdpTrace && ws.cdpTrace.length && shouldLog('debug')) {
922
+ const unique = [...new Set(ws.cdpTrace)];
923
+ console.log(`[CDP TRACE] ${id} methods (${ws.cdpTrace.length}): ${unique.join(', ')}`);
924
+ }
925
+
926
+ // 向 plugin 发送清理命令
927
+ if (ws.pairedPlugin) {
928
+ safeSend(ws.pairedPlugin, JSON.stringify({
929
+ type: 'client-disconnected',
930
+ clientId: id,
931
+ sessions: sessionsToClean
932
+ }), 'plugin');
933
+ if (shouldLog('debug')) {
934
+ console.log(` - Notified plugin of client disconnect`);
935
+ }
936
+ }
937
+
938
+ // 广播更新后的客户端列表
939
+ broadcastClientList();
940
+
941
+ // 清理配对关系
942
+ if (ws.pairedPlugin) {
943
+ ws.pairedPlugin.pairedClientId = null;
944
+ }
945
+ connectionPairs.delete(id);
946
+ });
947
+
948
+ // 错误处理
949
+ ws.on('error', (error) => {
950
+ console.error(`[CLIENT ERROR] ${id}:`, error.message);
951
+
952
+ logConnectionEvent('CLIENT_ERROR', {
953
+ id,
954
+ error: error.message,
955
+ totalPlugins: pluginConnections.size,
956
+ totalClients: clientConnections.size
957
+ });
958
+
959
+ clientConnections.delete(ws);
960
+ clientById.delete(id);
961
+
962
+ if (ws.pairedPlugin) {
963
+ ws.pairedPlugin.pairedClientId = null;
964
+ }
965
+ connectionPairs.delete(id);
966
+ });
967
+ }
968
+
969
+ function handlePageConnection(ws, clientInfo, targetId) {
970
+ clientConnections.add(ws);
971
+ const id = generateId('page');
972
+ if (shouldLog('info')) {
973
+ console.log(`\n[PAGE CONNECTED] ID: ${id}, targetId: ${targetId}`);
974
+ console.log(` - Remote: ${clientInfo.ip}:${clientInfo.port}`);
975
+ console.log(` - Total client connections: ${clientConnections.size}`);
976
+ }
977
+
978
+ ws.id = id;
979
+ ws.isAlive = true;
980
+ ws.cdpTrace = [];
981
+ ws.targetId = targetId;
982
+ ws.lastActivityTime = Date.now();
983
+ clientById.set(id, ws);
984
+
985
+ const plugin = pluginConnections.values().next().value;
986
+ if (plugin && plugin.readyState === WebSocket.OPEN) {
987
+ ws.pairedPlugin = plugin;
988
+ plugin.pairedClientId = id;
989
+ if (shouldLog('info')) {
990
+ console.log(` - Paired with plugin: ${plugin.id}`);
991
+ }
992
+ }
993
+
994
+ ws.on('pong', () => {
995
+ ws.isAlive = true;
996
+ });
997
+
998
+ const pluginMessageHandler = (data) => {
999
+ if (ws.readyState !== WebSocket.OPEN) {
1000
+ return;
1001
+ }
1002
+
1003
+ let msg;
1004
+ try {
1005
+ msg = JSON.parse(data.toString());
1006
+ } catch {
1007
+ return;
1008
+ }
1009
+
1010
+ if (msg.type === 'event' && msg.method) {
1011
+ const cdpMsg = {
1012
+ method: msg.method,
1013
+ params: msg.params
1014
+ };
1015
+ if (msg.sessionId) {
1016
+ cdpMsg.sessionId = msg.sessionId;
1017
+ }
1018
+
1019
+ if (msg.method === 'Page.screencastFrame' && shouldLog('debug')) {
1020
+ console.log(`[PLUGIN -> PAGE] ${id}: Page.screencastFrame`);
1021
+ }
1022
+ ws.lastActivityTime = Date.now();
1023
+ ws.send(JSON.stringify(cdpMsg));
1024
+ return;
1025
+ }
1026
+
1027
+ if (msg.id !== undefined || msg.method) {
1028
+ const messagePreview = msg.method || msg.id || 'response';
1029
+ if (shouldLog('debug')) {
1030
+ console.log(`[PLUGIN -> PAGE] ${id}: ${messagePreview}`);
1031
+ }
1032
+ ws.lastActivityTime = Date.now();
1033
+ ws.send(data.toString());
1034
+ }
1035
+ };
1036
+
1037
+ ws.pluginMessageHandler = pluginMessageHandler;
1038
+
1039
+ if (ws.pairedPlugin) {
1040
+ ws.pairedPlugin.on('message', pluginMessageHandler);
1041
+ }
1042
+
1043
+ ws.on('message', (data) => {
1044
+ ws.lastActivityTime = Date.now();
1045
+
1046
+ let parsed;
1047
+ try {
1048
+ parsed = JSON.parse(data);
1049
+ } catch {
1050
+ return;
1051
+ }
1052
+
1053
+ const messagePreview = parsed.method || parsed.id || 'response';
1054
+ if (shouldLog('debug')) {
1055
+ console.log(`[PAGE -> PLUGIN] ${id}: ${messagePreview}`);
1056
+ }
1057
+
1058
+ if (parsed && parsed.method) {
1059
+ ws.cdpTrace.push(parsed.method);
1060
+ if (ws.cdpTrace.length > CONFIG.CDP_TRACE_MAX_LENGTH) {
1061
+ ws.cdpTrace = ws.cdpTrace.slice(-CONFIG.CDP_TRACE_MAX_LENGTH);
1062
+ }
1063
+ }
1064
+
1065
+ if (ws.pairedPlugin) {
1066
+ const msg = { ...parsed, tabId: targetId };
1067
+ safeSend(ws.pairedPlugin, JSON.stringify(msg), 'plugin');
1068
+ }
1069
+ });
1070
+
1071
+ ws.on('close', (code, reason) => {
1072
+ clientConnections.delete(ws);
1073
+ clientById.delete(id);
1074
+ if (shouldLog('info')) {
1075
+ console.log(`\n[PAGE DISCONNECTED] ${id}`);
1076
+ console.log(` - Code: ${code}, Reason: ${reason || 'none'}`);
1077
+ console.log(` - Total client connections: ${clientConnections.size}`);
1078
+ }
1079
+
1080
+ if (ws.pairedPlugin && ws.pluginMessageHandler) {
1081
+ ws.pairedPlugin.off('message', ws.pluginMessageHandler);
1082
+ ws.pairedPlugin.pairedClientId = null;
1083
+
1084
+ safeSend(ws.pairedPlugin, JSON.stringify({
1085
+ type: 'client-disconnected',
1086
+ clientId: id,
1087
+ sessions: []
1088
+ }), 'plugin');
1089
+ if (shouldLog('debug')) {
1090
+ console.log(` - Notified plugin of page disconnect`);
1091
+ }
1092
+ }
1093
+
1094
+ ws.pluginMessageHandler = null;
1095
+ });
1096
+
1097
+ ws.on('error', (error) => {
1098
+ console.error(`[PAGE ERROR] ${id}:`, error.message);
1099
+
1100
+ clientConnections.delete(ws);
1101
+ clientById.delete(id);
1102
+
1103
+ if (ws.pairedPlugin && ws.pluginMessageHandler) {
1104
+ ws.pairedPlugin.off('message', ws.pluginMessageHandler);
1105
+ ws.pairedPlugin.pairedClientId = null;
1106
+ }
1107
+
1108
+ ws.pluginMessageHandler = null;
1109
+ });
1110
+ }
1111
+
1112
+ /**
1113
+ * 广播消息给所有客户端
1114
+ */
1115
+ function broadcastToClients(data, excludeWs = null) {
1116
+ let sent = 0;
1117
+ clientConnections.forEach((client) => {
1118
+ if (client !== excludeWs && safeSend(client, data, 'client')) {
1119
+ sent++;
1120
+ }
1121
+ });
1122
+ return sent;
1123
+ }
1124
+
1125
+ function broadcastToPlugins(data, excludeWs = null) {
1126
+ let sent = 0;
1127
+ pluginConnections.forEach((plugin) => {
1128
+ if (plugin !== excludeWs && safeSend(plugin, data, 'plugin')) {
1129
+ sent++;
1130
+ }
1131
+ });
1132
+ return sent;
1133
+ }
1134
+
1135
+ function broadcastClientList() {
1136
+ const clients = [];
1137
+ clientConnections.forEach((client) => {
1138
+ clients.push({
1139
+ id: client.id,
1140
+ connectedAt: client.connectedAt,
1141
+ lastActivity: client.lastActivityTime
1142
+ });
1143
+ });
1144
+
1145
+ broadcastToPlugins(JSON.stringify({
1146
+ type: 'client-list',
1147
+ clients: clients
1148
+ }));
1149
+ }
1150
+
1151
+ /**
1152
+ * 生成唯一 ID
1153
+ */
1154
+ function generateId(prefix = 'conn') {
1155
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
1156
+ }
1157
+
1158
+ const BUFFER_THRESHOLD = 1024 * 1024;
1159
+ const MAX_QUEUE_SIZE = 100;
1160
+ const messageQueues = new Map();
1161
+
1162
+ function safeSend(ws, data, label = '') {
1163
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
1164
+ return false;
1165
+ }
1166
+
1167
+ const wsId = ws.id || label;
1168
+
1169
+ if (ws.bufferedAmount > BUFFER_THRESHOLD) {
1170
+ if (shouldLog('warn')) {
1171
+ console.warn(`[BACKPRESSURE] ${wsId} buffer full (${Math.round(ws.bufferedAmount / 1024)}KB), queuing message`);
1172
+ }
1173
+
1174
+ if (!messageQueues.has(wsId)) {
1175
+ messageQueues.set(wsId, []);
1176
+ }
1177
+ const queue = messageQueues.get(wsId);
1178
+ if (queue.length < MAX_QUEUE_SIZE) {
1179
+ queue.push(data);
1180
+ if (shouldLog('debug')) {
1181
+ console.log(`[BACKPRESSURE] ${wsId} queue size: ${queue.length}`);
1182
+ }
1183
+ } else {
1184
+ if (shouldLog('warn')) {
1185
+ console.warn(`[BACKPRESSURE] ${wsId} queue full, dropping oldest message`);
1186
+ }
1187
+ queue.shift();
1188
+ queue.push(data);
1189
+ }
1190
+ return false;
1191
+ }
1192
+
1193
+ if (ws.lastActivityTime !== undefined) {
1194
+ ws.lastActivityTime = Date.now();
1195
+ }
1196
+
1197
+ try {
1198
+ ws.send(data);
1199
+ return true;
1200
+ } catch (e) {
1201
+ console.error(`[SEND_ERROR] ${wsId}:`, e.message);
1202
+ return false;
1203
+ }
1204
+ }
1205
+
1206
+ setInterval(() => {
1207
+ messageQueues.forEach((queue, wsId) => {
1208
+ let ws = null;
1209
+ for (const conn of pluginConnections) {
1210
+ if (conn.id === wsId) { ws = conn; break; }
1211
+ }
1212
+ if (!ws) {
1213
+ for (const conn of clientConnections) {
1214
+ if (conn.id === wsId) { ws = conn; break; }
1215
+ }
1216
+ }
1217
+
1218
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
1219
+ messageQueues.delete(wsId);
1220
+ if (shouldLog('debug')) {
1221
+ console.log(`[QUEUE] ${wsId} cleaned up (connection closed)`);
1222
+ }
1223
+ return;
1224
+ }
1225
+
1226
+ if (ws.bufferedAmount < BUFFER_THRESHOLD / 2) {
1227
+ const data = queue.shift();
1228
+ if (data) {
1229
+ try {
1230
+ ws.send(data);
1231
+ if (shouldLog('debug')) {
1232
+ console.log(`[QUEUE] ${wsId} sent queued message, remaining: ${queue.length}`);
1233
+ }
1234
+ } catch (e) {
1235
+ console.error(`[QUEUE_ERROR] ${wsId}:`, e.message);
1236
+ }
1237
+ }
1238
+ }
1239
+
1240
+ if (queue.length === 0) {
1241
+ messageQueues.delete(wsId);
1242
+ }
1243
+ });
1244
+ }, 100);
1245
+
1246
+ /**
1247
+ * 心跳检测 - 每 30 秒检查一次
1248
+ */
1249
+ const heartbeatInterval = setInterval(() => {
1250
+ const now = new Date().toISOString();
1251
+ const nowMs = Date.now();
1252
+
1253
+ // 检查 plugin 连接
1254
+ pluginConnections.forEach((ws) => {
1255
+ if (!ws.isAlive) {
1256
+ if (shouldLog('warn')) {
1257
+ console.log(`[${now}] Plugin ${ws.id} not responding, terminating...`);
1258
+ }
1259
+ logConnectionEvent('HEARTBEAT_TIMEOUT', { type: 'plugin', id: ws.id });
1260
+ pluginConnections.delete(ws);
1261
+ if (ws.pairedClientId) {
1262
+ connectionPairs.delete(ws.pairedClientId);
1263
+ const clientWs = clientById.get(ws.pairedClientId);
1264
+ if (clientWs) {
1265
+ clientWs.pairedPlugin = null;
1266
+ }
1267
+ }
1268
+ return ws.terminate();
1269
+ }
1270
+ ws.isAlive = false;
1271
+ ws.ping();
1272
+ logConnectionEvent('HEARTBEAT_PING', { type: 'plugin', id: ws.id, bufferedAmount: ws.bufferedAmount });
1273
+ });
1274
+
1275
+ // 检查 client 连接
1276
+ clientConnections.forEach((ws) => {
1277
+ if (!ws.isAlive) {
1278
+ if (shouldLog('warn')) {
1279
+ console.log(`[${now}] Client ${ws.id} not responding, terminating...`);
1280
+ }
1281
+ logConnectionEvent('HEARTBEAT_TIMEOUT', { type: 'client', id: ws.id });
1282
+ clientConnections.delete(ws);
1283
+ clientById.delete(ws.id);
1284
+ if (ws.pairedPlugin) {
1285
+ ws.pairedPlugin.pairedClientId = null;
1286
+ }
1287
+ connectionPairs.delete(ws.id);
1288
+ return ws.terminate();
1289
+ }
1290
+
1291
+ // 检查空闲超时
1292
+ if (ws.lastActivityTime && (nowMs - ws.lastActivityTime > CONFIG.CLIENT_IDLE_TIMEOUT)) {
1293
+ const idleSeconds = Math.round((nowMs - ws.lastActivityTime) / 1000);
1294
+ if (shouldLog('info')) {
1295
+ console.log(`[${now}] Client ${ws.id} idle for ${idleSeconds}s, closing...`);
1296
+ }
1297
+ logConnectionEvent('CLIENT_IDLE_TIMEOUT', {
1298
+ type: 'client',
1299
+ id: ws.id,
1300
+ idleSeconds,
1301
+ lastActivityTime: new Date(ws.lastActivityTime).toISOString()
1302
+ });
1303
+ ws.close(1001, `Idle timeout: no activity for ${idleSeconds} seconds`);
1304
+ return;
1305
+ }
1306
+
1307
+ ws.isAlive = false;
1308
+ ws.ping();
1309
+ logConnectionEvent('HEARTBEAT_PING', { type: 'client', id: ws.id, bufferedAmount: ws.bufferedAmount });
1310
+ });
1311
+ }, CONFIG.HEARTBEAT_INTERVAL);
1312
+
1313
+ setInterval(() => {
1314
+ const toRemove = [];
1315
+ pluginConnections.forEach(ws => {
1316
+ if (ws.readyState !== WebSocket.OPEN) {
1317
+ toRemove.push(ws);
1318
+ }
1319
+ });
1320
+ toRemove.forEach(ws => {
1321
+ pluginConnections.delete(ws);
1322
+ if (shouldLog('debug')) {
1323
+ console.log(`[CLEANUP] Removed zombie plugin: ${ws.id}, state: ${ws.readyState}`);
1324
+ }
1325
+ });
1326
+
1327
+ toRemove.length = 0;
1328
+ clientConnections.forEach(ws => {
1329
+ if (ws.readyState !== WebSocket.OPEN) {
1330
+ toRemove.push(ws);
1331
+ }
1332
+ });
1333
+ toRemove.forEach(ws => {
1334
+ clientConnections.delete(ws);
1335
+ clientById.delete(ws.id);
1336
+ if (shouldLog('debug')) {
1337
+ console.log(`[CLEANUP] Removed zombie client: ${ws.id}, state: ${ws.readyState}`);
1338
+ }
1339
+ });
1340
+ }, 60000);
1341
+
1342
+ /**
1343
+ * 服务器关闭处理
1344
+ */
1345
+ wss.on('close', () => {
1346
+ clearInterval(heartbeatInterval);
1347
+ console.log('\n[SERVER] Server closed');
1348
+ });
1349
+
1350
+ /**
1351
+ * 定期打印状态
1352
+ */
1353
+ setInterval(() => {
1354
+ const now = new Date().toISOString();
1355
+ const nowMs = Date.now();
1356
+
1357
+ const validPlugins = Array.from(pluginConnections).filter(ws => ws.readyState === WebSocket.OPEN);
1358
+ const zombiePlugins = Array.from(pluginConnections).filter(ws => ws.readyState !== WebSocket.OPEN);
1359
+ const validClients = Array.from(clientConnections).filter(ws => ws.readyState === WebSocket.OPEN);
1360
+ const zombieClients = Array.from(clientConnections).filter(ws => ws.readyState !== WebSocket.OPEN);
1361
+
1362
+ if (shouldLog('info')) {
1363
+ console.log(`\n[${now}] Status:`);
1364
+ console.log(` - Plugin connections: ${validPlugins.length} valid / ${pluginConnections.size} total`);
1365
+ console.log(` - Client connections: ${validClients.length} valid / ${clientConnections.size} total`);
1366
+ console.log(` - Active pairs: ${connectionPairs.size}`);
1367
+ }
1368
+
1369
+ if (zombiePlugins.length > 0 && shouldLog('warn')) {
1370
+ console.log(` - Zombie plugins: ${zombiePlugins.map(ws => `${ws.id}(${ws.readyState})`).join(', ')}`);
1371
+ }
1372
+ if (zombieClients.length > 0 && shouldLog('warn')) {
1373
+ console.log(` - Zombie clients: ${zombieClients.map(ws => `${ws.id}(${ws.readyState})`).join(', ')}`);
1374
+ }
1375
+
1376
+ const pluginList = Array.from(pluginConnections).map(ws => ({
1377
+ id: ws.id,
1378
+ readyState: ws.readyState,
1379
+ pairedClientId: ws.pairedClientId,
1380
+ bufferedAmount: ws.bufferedAmount,
1381
+ isAlive: ws.isAlive
1382
+ }));
1383
+
1384
+ const clientList = Array.from(clientConnections).map(ws => {
1385
+ const idleMs = ws.lastActivityTime ? nowMs - ws.lastActivityTime : 0;
1386
+ return {
1387
+ id: ws.id,
1388
+ readyState: ws.readyState,
1389
+ hasPairedPlugin: !!ws.pairedPlugin,
1390
+ bufferedAmount: ws.bufferedAmount,
1391
+ isAlive: ws.isAlive,
1392
+ idleSeconds: Math.round(idleMs / 1000)
1393
+ };
1394
+ });
1395
+
1396
+ logStatus({
1397
+ timestamp: now,
1398
+ plugins: pluginConnections.size,
1399
+ validPlugins: validPlugins.length,
1400
+ clients: clientConnections.size,
1401
+ validClients: validClients.length,
1402
+ pairs: connectionPairs.size,
1403
+ pluginDetails: pluginList,
1404
+ clientDetails: clientList,
1405
+ sessions: sessionToClientId.size,
1406
+ pendingAttach: pendingAttachRequests.size
1407
+ });
1408
+ }, CONFIG.STATUS_PRINT_INTERVAL);
1409
+
1410
+ // 优雅关闭
1411
+ process.on('SIGINT', () => {
1412
+ console.log('\n[SERVER] Shutting down...');
1413
+ clearInterval(heartbeatInterval);
1414
+
1415
+ // 关闭所有连接
1416
+ pluginConnections.forEach(ws => ws.close(1001, 'Server shutting down'));
1417
+ clientConnections.forEach(ws => ws.close(1001, 'Server shutting down'));
1418
+
1419
+ wss.close(() => {
1420
+ console.log('[SERVER] Server closed');
1421
+ flushAllLogs();
1422
+ process.exit(0);
1423
+ });
1424
+ });
1425
+
1426
+ process.on('SIGTERM', () => {
1427
+ flushAllLogs();
1428
+ process.exit(0);
1429
+ });
1430
+
1431
+ server.listen(PORT, '0.0.0.0');