cdp-tunnel 2.5.20 → 2.5.22

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.
@@ -18,10 +18,23 @@ const { execSync, spawn: spawnProcess } = require('child_process');
18
18
  const { CONFIG, BROWSER_ID, shouldLog } = require('./modules/config');
19
19
  const { logCDP, logEvent, clearLog, logStatus, logConnectionEvent, flushAllLogs, logDisconnect } = require('./modules/logger');
20
20
 
21
+ try {
22
+ const { validateApiKey } = require('./saas/auth');
23
+ var HAS_SAAS = true;
24
+ } catch (e) {
25
+ var HAS_SAAS = false;
26
+ }
27
+
21
28
  const PORT = CONFIG.PORT;
22
29
  const CONFIG_DIR = path.join(os.homedir(), '.cdp-tunnel');
23
- const EXTENSION_STATE_FILE = path.join(CONFIG_DIR, 'extension-state.json');
24
- const PLUGIN_EVER_CONNECTED_FILE = path.join(CONFIG_DIR, 'plugin-ever-connected');
30
+ const INSTANCE_DIR = path.join(CONFIG_DIR, 'instances', PORT.toString());
31
+
32
+ if (!fs.existsSync(INSTANCE_DIR)) {
33
+ fs.mkdirSync(INSTANCE_DIR, { recursive: true });
34
+ }
35
+
36
+ const EXTENSION_STATE_FILE = path.join(INSTANCE_DIR, 'extension-state.json');
37
+ const PLUGIN_EVER_CONNECTED_FILE = path.join(INSTANCE_DIR, 'plugin-ever-connected');
25
38
  const SERVER_START_TIME = Date.now();
26
39
 
27
40
  let lastChromeRestartAttempt = 0;
@@ -142,25 +155,38 @@ const server = http.createServer((req, res) => handleHttpRequest(req, res));
142
155
  const pluginConnections = new Set();
143
156
  const clientConnections = new Set();
144
157
 
158
+ class PluginNamespace {
159
+ constructor() {
160
+ this.sessionToClientId = new Map();
161
+ this.pendingAttachRequests = new Map();
162
+ this.pendingAttachedEvents = new Map();
163
+ this.pendingTargetCreatedEvents = new Map();
164
+ this.targetIdToClientId = new Map();
165
+ this.browserContextToClientId = new Map();
166
+ this.clientIdToBrowserContext = new Map();
167
+ this.cachedTargets = [];
168
+ this.lastTargetsUpdate = 0;
169
+ this.cachedBrowserVersion = null;
170
+ }
171
+ }
172
+
173
+ const pluginNamespaces = new Map();
174
+
175
+ function getNamespace(pluginWs) {
176
+ if (!pluginNamespaces.has(pluginWs)) {
177
+ pluginNamespaces.set(pluginWs, new PluginNamespace());
178
+ }
179
+ return pluginNamespaces.get(pluginWs);
180
+ }
181
+
145
182
  const connectionPairs = new Map();
146
183
  const clientById = new Map();
147
- const sessionToClientId = new Map();
148
- const pendingAttachRequests = new Map();
149
184
  const clientIdToPlugin = new Map();
150
185
  const globalRequestIdMap = new Map();
151
- const targetIdToClientId = new Map();
152
- const pendingAttachedEvents = new Map();
153
- const pendingTargetCreatedEvents = new Map();
154
- const browserContextToClientId = new Map();
155
- const clientIdToBrowserContext = new Map();
156
186
  let globalRequestIdCounter = 0;
157
187
 
158
188
  const { version: PKG_VERSION } = require('../package.json');
159
189
 
160
- let cachedTargets = [];
161
- let lastTargetsUpdate = 0;
162
- let cachedBrowserVersion = null;
163
-
164
190
  console.log('='.repeat(60));
165
191
  console.log(` WebSocket CDP Proxy Server v${PKG_VERSION}`);
166
192
  console.log('='.repeat(60));
@@ -177,26 +203,24 @@ function getHost(req) {
177
203
  return req.headers.host || `localhost:${PORT}`;
178
204
  }
179
205
 
180
- /**
181
- * 生成 WebSocket 调试地址
182
- */
183
- function buildWebSocketDebuggerUrl(req) {
184
- return `ws://${getHost(req)}/devtools/browser/${BROWSER_ID}`;
185
- }
186
-
187
- function buildTargetWebSocketUrl(req, targetId) {
188
- return `ws://${getHost(req)}/devtools/page/${targetId}`;
206
+ function invalidateTargetsCache(pluginWs) {
207
+ if (pluginWs) {
208
+ getNamespace(pluginWs).lastTargetsUpdate = 0;
209
+ } else {
210
+ pluginNamespaces.forEach(ns => { ns.lastTargetsUpdate = 0; });
211
+ }
189
212
  }
190
213
 
191
- function invalidateTargetsCache() {
192
- lastTargetsUpdate = 0;
193
- }
214
+ async function requestVersionFromPlugin(pluginWs) {
215
+ if (!pluginWs) {
216
+ pluginWs = pluginConnections.values().next().value;
217
+ }
218
+ if (!pluginWs) return null;
194
219
 
195
- async function requestVersionFromPlugin() {
196
- if (cachedBrowserVersion) return cachedBrowserVersion;
220
+ const ns = getNamespace(pluginWs);
221
+ if (ns.cachedBrowserVersion) return ns.cachedBrowserVersion;
197
222
 
198
- const plugin = pluginConnections.values().next().value;
199
- if (!plugin || plugin.readyState !== WebSocket.OPEN) {
223
+ if (pluginWs.readyState !== WebSocket.OPEN) {
200
224
  return null;
201
225
  }
202
226
 
@@ -209,35 +233,40 @@ async function requestVersionFromPlugin() {
209
233
  const msg = JSON.parse(data.toString());
210
234
  if (msg.id === requestId && msg.result) {
211
235
  clearTimeout(timeout);
212
- plugin.off('message', handler);
236
+ pluginWs.off('message', handler);
213
237
  if (msg.result.product || msg.result.userAgent) {
214
- cachedBrowserVersion = msg.result;
238
+ ns.cachedBrowserVersion = msg.result;
215
239
  }
216
- resolve(cachedBrowserVersion || msg.result);
240
+ resolve(ns.cachedBrowserVersion || msg.result);
217
241
  }
218
242
  } catch (e) {}
219
243
  };
220
244
 
221
- plugin.on('message', handler);
222
- plugin.send(JSON.stringify({ id: requestId, method: 'Browser.getVersion' }));
245
+ pluginWs.on('message', handler);
246
+ pluginWs.send(JSON.stringify({ id: requestId, method: 'Browser.getVersion' }));
223
247
  });
224
248
  }
225
249
 
226
- async function requestTargetsFromPlugin() {
250
+ async function requestTargetsFromPlugin(pluginWs) {
251
+ if (!pluginWs) {
252
+ pluginWs = pluginConnections.values().next().value;
253
+ }
254
+ if (!pluginWs) return [];
255
+
256
+ const ns = getNamespace(pluginWs);
227
257
  const now = Date.now();
228
- if (now - lastTargetsUpdate < CONFIG.TARGETS_CACHE_TTL && cachedTargets.length > 0) {
229
- return cachedTargets;
258
+ if (now - ns.lastTargetsUpdate < CONFIG.TARGETS_CACHE_TTL && ns.cachedTargets.length > 0) {
259
+ return ns.cachedTargets;
230
260
  }
231
261
 
232
- const plugin = pluginConnections.values().next().value;
233
- if (!plugin || plugin.readyState !== WebSocket.OPEN) {
234
- return cachedTargets;
262
+ if (pluginWs.readyState !== WebSocket.OPEN) {
263
+ return ns.cachedTargets;
235
264
  }
236
265
 
237
266
  return new Promise((resolve) => {
238
267
  const requestId = `targets_${Date.now()}`;
239
268
  const timeout = setTimeout(() => {
240
- resolve(cachedTargets);
269
+ resolve(ns.cachedTargets);
241
270
  }, CONFIG.TARGETS_REQUEST_TIMEOUT);
242
271
 
243
272
  const handler = (data) => {
@@ -245,36 +274,79 @@ async function requestTargetsFromPlugin() {
245
274
  const msg = JSON.parse(data.toString());
246
275
  if (msg.id === requestId && msg.result?.targetInfos) {
247
276
  clearTimeout(timeout);
248
- plugin.off('message', handler);
249
- cachedTargets = msg.result.targetInfos;
250
- lastTargetsUpdate = now;
251
- resolve(cachedTargets);
277
+ pluginWs.off('message', handler);
278
+ ns.cachedTargets = msg.result.targetInfos;
279
+ ns.lastTargetsUpdate = now;
280
+ resolve(ns.cachedTargets);
252
281
  }
253
282
  } catch (e) {}
254
283
  };
255
284
 
256
- plugin.on('message', handler);
257
- plugin.send(JSON.stringify({ id: requestId, method: 'Target.getTargets' }));
285
+ pluginWs.on('message', handler);
286
+ pluginWs.send(JSON.stringify({ id: requestId, method: 'Target.getTargets' }));
258
287
  });
259
288
  }
260
289
 
290
+ /**
291
+ * 生成指定 plugin 的 browser WS URL
292
+ */
293
+ function buildBrowserWsUrl(pluginId) {
294
+ const host = CONFIG.EXTERNAL_HOST || `localhost:${PORT}`;
295
+ return `ws://${host}/devtools/browser/${pluginId}`;
296
+ }
297
+
261
298
  /**
262
299
  * 处理 HTTP 请求
263
300
  */
301
+ function resolvePluginFromUrl(url) {
302
+ const parts = url.pathname.split('/').filter(Boolean);
303
+ if (parts.length >= 3) {
304
+ const pluginId = parts[2];
305
+ for (const pluginWs of pluginConnections) {
306
+ if (pluginWs.pluginId === pluginId) return pluginWs;
307
+ }
308
+ }
309
+ return pluginConnections.values().next().value || null;
310
+ }
311
+
264
312
  async function handleHttpRequest(req, res) {
265
313
  const url = new URL(req.url, `http://localhost:${PORT}`);
266
314
 
267
- if (url.pathname === '/json/version' || url.pathname === '/json/version/') {
268
- const ver = await requestVersionFromPlugin();
315
+ if (url.pathname === '/json/browsers' || url.pathname === '/json/browsers/') {
316
+ const browsers = [];
317
+ for (const pluginWs of pluginConnections) {
318
+ if (pluginWs.readyState !== WebSocket.OPEN) continue;
319
+ const ns = getNamespace(pluginWs);
320
+ browsers.push({
321
+ pluginId: pluginWs.pluginId,
322
+ pluginName: pluginWs.pluginName || 'My Browser',
323
+ userId: pluginWs.userId || null,
324
+ browserName: ns.cachedBrowserVersion?.Browser || 'Unknown',
325
+ targets: ns.cachedTargets.length,
326
+ connected: true,
327
+ connectedAt: pluginWs.connectedAt,
328
+ webSocketDebuggerUrl: buildBrowserWsUrl(pluginWs.pluginId)
329
+ });
330
+ }
331
+ res.writeHead(200, { 'Content-Type': 'application/json' });
332
+ res.end(JSON.stringify(browsers));
333
+ return;
334
+ }
335
+
336
+ if (url.pathname === '/json/version' || url.pathname === '/json/version/' ||
337
+ url.pathname.match(/^\/json\/version\/[^/]+$/)) {
338
+ const pluginWs = resolvePluginFromUrl(url);
339
+ const ver = await requestVersionFromPlugin(pluginWs);
269
340
  const userAgent = ver?.userAgent || 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.86 Safari/537.36';
270
341
  const product = ver?.product || 'Chrome/131.0.6778.86';
342
+ const browserId = pluginWs ? pluginWs.pluginId : BROWSER_ID;
271
343
  const payload = {
272
344
  Browser: `${product} (cdp-tunnel/${PKG_VERSION})`,
273
345
  'Protocol-Version': ver?.protocolVersion || '1.3',
274
346
  'User-Agent': userAgent,
275
347
  'V8-Version': ver?.jsVersion || '',
276
348
  'WebKit-Version': '537.36',
277
- webSocketDebuggerUrl: buildWebSocketDebuggerUrl(req)
349
+ webSocketDebuggerUrl: `ws://${getHost(req)}/devtools/browser/${browserId}`
278
350
  };
279
351
  res.writeHead(200, { 'Content-Type': 'application/json' });
280
352
  res.end(JSON.stringify(payload));
@@ -282,16 +354,19 @@ async function handleHttpRequest(req, res) {
282
354
  }
283
355
 
284
356
  if (url.pathname === '/json' || url.pathname === '/json/' ||
285
- url.pathname === '/json/list' || url.pathname === '/json/list/') {
286
- const targets = await requestTargetsFromPlugin();
357
+ url.pathname === '/json/list' || url.pathname === '/json/list/' ||
358
+ url.pathname.match(/^\/json\/list\/[^/]+$/)) {
359
+ const pluginWs = resolvePluginFromUrl(url);
360
+ const targets = await requestTargetsFromPlugin(pluginWs);
361
+ const browserId = pluginWs ? pluginWs.pluginId : BROWSER_ID;
287
362
  const targetList = targets
288
363
  .filter(t => {
289
364
  if (t.type !== 'page') return false;
290
- const url = t.url || '';
291
- if (url.startsWith('chrome://') ||
292
- url.startsWith('chrome-extension://') ||
293
- url.startsWith('devtools://') ||
294
- url.startsWith('edge://')) {
365
+ const tUrl = t.url || '';
366
+ if (tUrl.startsWith('chrome://') ||
367
+ tUrl.startsWith('chrome-extension://') ||
368
+ tUrl.startsWith('devtools://') ||
369
+ tUrl.startsWith('edge://')) {
295
370
  return false;
296
371
  }
297
372
  return true;
@@ -305,7 +380,7 @@ async function handleHttpRequest(req, res) {
305
380
  title: t.title || '',
306
381
  type: t.type,
307
382
  url: t.url || '',
308
- webSocketDebuggerUrl: buildTargetWebSocketUrl(req, t.targetId)
383
+ webSocketDebuggerUrl: `ws://${getHost(req)}/devtools/page/${t.targetId}`
309
384
  }));
310
385
  res.writeHead(200, { 'Content-Type': 'application/json' });
311
386
  res.end(JSON.stringify(targetList));
@@ -322,8 +397,10 @@ async function handleHttpRequest(req, res) {
322
397
  server.on('upgrade', (req, socket, head) => {
323
398
  const url = new URL(req.url, `http://localhost:${PORT}`);
324
399
  const path = url.pathname;
400
+ const pathParts = path.split('/').filter(Boolean);
325
401
  const isPlugin = path === '/plugin';
326
402
  const isClient = path === '/client' ||
403
+ path.startsWith('/client/') ||
327
404
  path.startsWith('/client-') ||
328
405
  path.startsWith('/devtools/browser/') ||
329
406
  path.startsWith('/devtools/page/');
@@ -341,6 +418,7 @@ server.on('upgrade', (req, socket, head) => {
341
418
  wss.on('connection', (ws, req) => {
342
419
  const url = new URL(req.url, `http://localhost:${PORT}`);
343
420
  const path = url.pathname;
421
+ const pathParts = path.split('/').filter(Boolean);
344
422
 
345
423
  const clientInfo = {
346
424
  ip: req.socket.remoteAddress,
@@ -348,10 +426,16 @@ wss.on('connection', (ws, req) => {
348
426
  };
349
427
 
350
428
  if (path === '/plugin') {
351
- handlePluginConnection(ws, clientInfo);
352
- } else if (path === '/client' || path.startsWith('/client-') || path.startsWith('/devtools/browser/')) {
429
+ handlePluginConnection(ws, clientInfo, req);
430
+ } else if (path === '/client' || path.startsWith('/client/') || path.startsWith('/client-') || path.startsWith('/devtools/browser/')) {
353
431
  const customClientId = path.startsWith('/client-') ? path.replace('/client-', '') : null;
354
- handleClientConnection(ws, clientInfo, customClientId);
432
+ let targetPluginId = null;
433
+ if (pathParts[0] === 'client' && pathParts[1]) {
434
+ targetPluginId = pathParts[1];
435
+ } else if (pathParts[0] === 'devtools' && pathParts[1] === 'browser' && pathParts[2]) {
436
+ targetPluginId = pathParts[2];
437
+ }
438
+ handleClientConnection(ws, clientInfo, customClientId, targetPluginId);
355
439
  } else if (path.startsWith('/devtools/page/')) {
356
440
  const targetId = path.replace('/devtools/page/', '');
357
441
  handlePageConnection(ws, clientInfo, targetId);
@@ -362,21 +446,26 @@ wss.on('connection', (ws, req) => {
362
446
  });
363
447
 
364
448
  function cleanupClient(ws, id, reason) {
365
- const sessionsToClean = [];
366
- for (const [sessionId, clientId] of sessionToClientId.entries()) {
367
- if (clientId === id) {
368
- sessionsToClean.push(sessionId);
369
- sessionToClientId.delete(sessionId);
449
+ const pluginWs = ws.pairedPlugin || clientIdToPlugin.get(id);
450
+ const ns = pluginWs ? getNamespace(pluginWs) : null;
451
+
452
+ if (ns) {
453
+ const sessionsToClean = [];
454
+ for (const [sessionId, clientId] of ns.sessionToClientId.entries()) {
455
+ if (clientId === id) {
456
+ sessionsToClean.push(sessionId);
457
+ ns.sessionToClientId.delete(sessionId);
458
+ }
370
459
  }
371
460
  }
372
461
 
373
462
  clientConnections.delete(ws);
374
463
  clientById.delete(id);
464
+ clientIdToPlugin.delete(id);
375
465
 
376
466
  logConnectionEvent('CLIENT_DISCONNECTED', {
377
467
  id,
378
468
  reason,
379
- sessionsCleaned: sessionsToClean.length,
380
469
  totalPlugins: pluginConnections.size,
381
470
  totalClients: clientConnections.size
382
471
  });
@@ -384,7 +473,6 @@ function cleanupClient(ws, id, reason) {
384
473
  logDisconnect('CLIENT_CLEANUP', {
385
474
  clientId: id,
386
475
  reason,
387
- sessionsLost: sessionsToClean.length,
388
476
  cdpMethodsUsed: ws.cdpTrace ? [...new Set(ws.cdpTrace)] : [],
389
477
  uptime: ws.connectedAt ? `${((Date.now() - ws.connectedAt) / 1000).toFixed(0)}s` : 'unknown',
390
478
  remainingClients: clientConnections.size,
@@ -401,20 +489,22 @@ function cleanupClient(ws, id, reason) {
401
489
  safeSend(ws.pairedPlugin, JSON.stringify({
402
490
  type: 'client-disconnected',
403
491
  clientId: id,
404
- sessions: sessionsToClean
492
+ sessions: []
405
493
  }), 'plugin');
406
494
  }
407
495
 
408
496
  broadcastClientList();
409
497
 
410
- for (const [tId, cId] of targetIdToClientId.entries()) {
411
- if (cId === id) targetIdToClientId.delete(tId);
412
- }
413
- for (const [bcId, cId] of browserContextToClientId.entries()) {
414
- if (cId === id) browserContextToClientId.delete(bcId);
415
- }
416
- if (clientIdToBrowserContext.has(id)) {
417
- clientIdToBrowserContext.delete(id);
498
+ if (ns) {
499
+ for (const [tId, cId] of ns.targetIdToClientId.entries()) {
500
+ if (cId === id) ns.targetIdToClientId.delete(tId);
501
+ }
502
+ for (const [bcId, cId] of ns.browserContextToClientId.entries()) {
503
+ if (cId === id) ns.browserContextToClientId.delete(bcId);
504
+ }
505
+ if (ns.clientIdToBrowserContext.has(id)) {
506
+ ns.clientIdToBrowserContext.delete(id);
507
+ }
418
508
  }
419
509
  for (const [gId, mapping] of globalRequestIdMap.entries()) {
420
510
  if (mapping.clientId === id) globalRequestIdMap.delete(gId);
@@ -446,7 +536,9 @@ function sendPendingRequestErrors(pluginWs) {
446
536
  }
447
537
 
448
538
  function cleanupPlugin(ws, id, reason) {
539
+ const ns = getNamespace(ws);
449
540
  pluginConnections.delete(ws);
541
+ pluginNamespaces.delete(ws);
450
542
 
451
543
  if (pluginConnections.size === 0) {
452
544
  updateExtensionState(false);
@@ -478,8 +570,8 @@ function cleanupPlugin(ws, id, reason) {
478
570
  remainingPlugins: pluginConnections.size,
479
571
  affectedClients,
480
572
  uptime: ws.connectedAt ? `${((Date.now() - ws.connectedAt) / 1000).toFixed(0)}s` : 'unknown',
481
- activeSessions: sessionToClientId.size,
482
- pendingRequests: pendingAttachRequests.size
573
+ activeSessions: ns.sessionToClientId.size,
574
+ pendingRequests: ns.pendingAttachRequests.size
483
575
  });
484
576
 
485
577
  if (ws.pairedClientId) {
@@ -490,35 +582,46 @@ function cleanupPlugin(ws, id, reason) {
490
582
  /**
491
583
  * 处理 Chrome 扩展连接
492
584
  */
493
- function handlePluginConnection(ws, clientInfo) {
585
+ function handlePluginConnection(ws, clientInfo, request) {
586
+ const req = request;
494
587
  const id = generateId('plugin');
495
588
 
496
- if (pluginConnections.size > 0) {
497
- const toRemove = [];
498
- pluginConnections.forEach(oldWs => {
499
- if (oldWs !== ws) {
500
- if (oldWs.readyState === WebSocket.OPEN) {
501
- oldWs.send(JSON.stringify({ type: 'server-restart' }));
502
- oldWs.close(1001, 'Server restarted');
589
+ ws.pluginId = 'browser_' + Date.now() + '_' + Math.random().toString(36).substr(2, 8);
590
+
591
+ try {
592
+ const url = new URL(req.url, `http://localhost`);
593
+ const apiKey = url.searchParams.get('key');
594
+ const desiredPluginId = url.searchParams.get('pluginId');
595
+
596
+ if (desiredPluginId && /^browser_[a-zA-Z0-9_]+$/.test(desiredPluginId)) {
597
+ let conflict = false;
598
+ for (const existing of pluginConnections) {
599
+ if (existing.pluginId === desiredPluginId && existing !== ws) {
600
+ conflict = true;
601
+ break;
503
602
  }
504
- toRemove.push(oldWs);
505
603
  }
506
- });
507
- toRemove.forEach(oldWs => {
508
- pluginConnections.delete(oldWs);
509
- if (shouldLog('info')) {
510
- console.log(`[PLUGIN] Removed old connection: ${oldWs.id}`);
604
+ if (!conflict) {
605
+ ws.pluginId = desiredPluginId;
511
606
  }
512
- });
607
+ }
608
+
609
+ if (HAS_SAAS && apiKey) {
610
+ const keyInfo = validateApiKey(apiKey);
611
+ if (keyInfo) {
612
+ ws.userId = keyInfo.userId;
613
+ ws.apiKeyId = keyInfo.keyId;
614
+ logConnectionEvent('PLUGIN_AUTHED', `userId=${keyInfo.userId} keyName=${keyInfo.keyName}`);
615
+ } else {
616
+ logConnectionEvent('PLUGIN_AUTH_FAIL', 'Invalid API key');
617
+ ws.close(4001, 'Invalid API key');
618
+ return;
619
+ }
620
+ }
621
+ } catch (e) {
622
+ logConnectionEvent('PLUGIN_AUTH_ERR', e.message);
513
623
  }
514
624
 
515
- sessionToClientId.clear();
516
- pendingAttachRequests.clear();
517
- connectionPairs.clear();
518
- clientConnections.forEach(clientWs => {
519
- clientWs.pairedPlugin = null;
520
- });
521
-
522
625
  pluginConnections.add(ws);
523
626
 
524
627
  const pluginType = 'plugin';
@@ -626,21 +729,28 @@ function handlePluginConnection(ws, clientInfo) {
626
729
  if (!match) {
627
730
  console.log(` ↳ Run "cdp-tunnel update" or reload the extension to sync versions`);
628
731
  }
629
- cachedBrowserVersion = null;
630
- requestVersionFromPlugin();
732
+ getNamespace(ws).cachedBrowserVersion = null;
733
+ requestVersionFromPlugin(ws);
631
734
  return;
632
735
  }
633
736
 
634
737
  console.log(`[PLUGIN MSG] id=${parsed?.id} method=${parsed?.method || 'none'} type=${parsed?.type || 'none'} sessionId=${parsed?.sessionId?.substring(0,8) || 'none'}`);
635
738
 
739
+ if (parsed?.type === 'tabgroup-debug') {
740
+ console.log(`[TABGROUP DEBUG] ${JSON.stringify(parsed)}`);
741
+ }
742
+
636
743
  // 记录所有 PLUGIN -> CLIENT 消息到日志文件
637
744
  logCDP('PLUGIN -> CLIENT', data.toString().substring(0, CONFIG.LOG_MESSAGE_PREVIEW_LENGTH), parsed?.sessionId, ws.pluginType);
638
745
 
639
746
  // 处理 type: 'event' 消息(来自 background.js 的 screencast 等事件)
640
747
  if (parsed && parsed.type === 'event' && parsed.method) {
641
- // 对于 Target 事件,始终广播给所有客户端
748
+ if (parsed.method.startsWith('CDPTunnel.')) {
749
+ console.log(`[EXT DEBUG] ${parsed.method}: ${JSON.stringify(parsed.params)}`);
750
+ }
642
751
  const targetEvents = ['Target.targetCreated', 'Target.attachedToTarget', 'Target.targetDestroyed', 'Target.targetInfoChanged'];
643
752
  if (targetEvents.includes(parsed.method)) {
753
+ const ns = getNamespace(ws);
644
754
  const cdpMsg = {
645
755
  method: parsed.method,
646
756
  params: parsed.params,
@@ -655,19 +765,19 @@ function handlePluginConnection(ws, clientInfo) {
655
765
  const targetId = parsed.params?.targetInfo?.targetId;
656
766
  const openerId = parsed.params?.targetInfo?.openerId;
657
767
  if (openerId && targetId) {
658
- const openerClientId = targetIdToClientId.get(openerId);
768
+ const openerClientId = ns.targetIdToClientId.get(openerId);
659
769
  if (openerClientId) {
660
- targetIdToClientId.set(targetId, openerClientId);
770
+ ns.targetIdToClientId.set(targetId, openerClientId);
661
771
  console.log(`[TARGET CREATED with opener] targetId=${targetId?.substring(0,8) || 'none'} openerId=${openerId?.substring(0,8) || 'none'} -> clientId=${openerClientId}`);
662
772
  }
663
773
  }
664
774
  }
665
775
 
666
- rewriteBrowserContextId(cdpMsg);
776
+ rewriteBrowserContextId(cdpMsg, ws);
667
777
  const cdpData = JSON.stringify(cdpMsg);
668
778
 
669
779
  const targetId = parsed.params?.targetInfo?.targetId;
670
- const eventClientId = targetId ? targetIdToClientId.get(targetId) : null;
780
+ const eventClientId = targetId ? ns.targetIdToClientId.get(targetId) : null;
671
781
 
672
782
  if (eventClientId) {
673
783
  const clientWs = clientById.get(eventClientId);
@@ -676,7 +786,7 @@ function handlePluginConnection(ws, clientInfo) {
676
786
  console.log(`[TARGET EVENT ROUTED] ${parsed.method} targetId=${targetId?.substring(0,8)} -> clientId=${eventClientId}`);
677
787
  }
678
788
  } else if (targetId && (parsed.method === 'Target.targetCreated' || parsed.method === 'Target.attachedToTarget')) {
679
- const pendingMap = parsed.method === 'Target.targetCreated' ? pendingTargetCreatedEvents : pendingAttachedEvents;
789
+ const pendingMap = parsed.method === 'Target.targetCreated' ? ns.pendingTargetCreatedEvents : ns.pendingAttachedEvents;
680
790
  pendingMap.set(targetId, { parsed: JSON.parse(JSON.stringify(parsed)), cdpData });
681
791
  console.log(`[TARGET EVENT PENDING] ${parsed.method} targetId=${targetId?.substring(0,8)} (cached, waiting for createTarget response)`);
682
792
  } else {
@@ -684,24 +794,23 @@ function handlePluginConnection(ws, clientInfo) {
684
794
  }
685
795
  }
686
796
 
687
- // 对于 Target.attachedToTarget 事件,建立 sessionId -> clientId 映射
688
797
  if (parsed.method === 'Target.attachedToTarget') {
798
+ const ns = getNamespace(ws);
689
799
  const targetId = parsed.params?.targetInfo?.targetId;
690
800
  const sessionId = parsed.params?.sessionId;
691
801
 
692
802
  if (targetId && sessionId) {
693
803
  const clientId = ws.pairedClientId;
694
804
  if (clientId) {
695
- sessionToClientId.set(sessionId, clientId);
805
+ ns.sessionToClientId.set(sessionId, clientId);
696
806
  console.log(`[SESSION MAPPED] sessionId=${sessionId?.substring(0,8) || 'none'} -> clientId=${clientId?.substring(0,8) || 'none'}`);
697
807
  } else {
698
- sessionToClientId.set(sessionId, targetId);
808
+ ns.sessionToClientId.set(sessionId, targetId);
699
809
  console.log(`[SESSION MAPPED] sessionId=${sessionId?.substring(0,8) || 'none'} -> targetId=${targetId?.substring(0,8) || 'none'} (no pairedClientId)`);
700
810
  }
701
811
  }
702
812
  }
703
813
 
704
- // 如果不是 Target 事件,按照原来的逻辑发送
705
814
  if (!targetEvents.includes(parsed.method)) {
706
815
  const cdpMsg = {
707
816
  method: parsed.method,
@@ -737,45 +846,42 @@ function handlePluginConnection(ws, clientInfo) {
737
846
  if (parsed && parsed.id !== undefined) {
738
847
  const globalId = parsed.id;
739
848
  const mapping = globalRequestIdMap.get(globalId);
849
+ const ns = getNamespace(ws);
740
850
  console.log(`[RESPONSE DEBUG] globalId=${globalId} hasMapping=${!!mapping} sessionId=${parsed.sessionId?.substring(0,8) || 'none'} method=${parsed.method || 'response'}`);
741
851
  if (mapping) {
742
852
  const clientWs = clientById.get(mapping.clientId);
743
853
  if (clientWs && clientWs.readyState === WebSocket.OPEN) {
744
- // 如果是 Target.createBrowserContext 响应,记录 browserContextId -> clientId 映射
745
854
  if (mapping.isCreateBrowserContext && parsed.result?.browserContextId) {
746
855
  const browserContextId = parsed.result.browserContextId;
747
- browserContextToClientId.set(browserContextId, mapping.clientId);
748
- clientIdToBrowserContext.set(mapping.clientId, browserContextId);
856
+ ns.browserContextToClientId.set(browserContextId, mapping.clientId);
857
+ ns.clientIdToBrowserContext.set(mapping.clientId, browserContextId);
749
858
  console.log(`[BROWSER CONTEXT MAPPED] browserContextId=${browserContextId} -> clientId=${mapping.clientId}`);
750
859
  }
751
860
 
752
- // 如果是 Target.attachToTarget 响应,建立 sessionId -> clientId 映射
753
861
  if (parsed.result?.sessionId && mapping.method === 'Target.attachToTarget') {
754
- sessionToClientId.set(parsed.result.sessionId, mapping.clientId);
862
+ ns.sessionToClientId.set(parsed.result.sessionId, mapping.clientId);
755
863
  console.log(`[SESSION MAPPED from attach response] sessionId=${parsed.result.sessionId?.substring(0,8)} -> clientId=${mapping.clientId?.substring(0,8)}`);
756
864
  }
757
865
 
758
- // 如果是 Target.createTarget 响应,先发送缓存的 Target.attachedToTarget 事件
759
- // 然后再发送响应
760
866
  if (mapping.isCreateTarget && parsed.result?.targetId) {
761
867
  const targetId = parsed.result.targetId;
762
- targetIdToClientId.set(targetId, mapping.clientId);
763
- console.log(`[TARGET MAPPED] targetId=${targetId} -> clientId=${mapping.clientId} mapSize=${targetIdToClientId.size}`);
868
+ ns.targetIdToClientId.set(targetId, mapping.clientId);
869
+ console.log(`[TARGET MAPPED] targetId=${targetId} -> clientId=${mapping.clientId} mapSize=${ns.targetIdToClientId.size}`);
764
870
 
765
- const cachedCreated = pendingTargetCreatedEvents.get(targetId);
871
+ const cachedCreated = ns.pendingTargetCreatedEvents.get(targetId);
766
872
  if (cachedCreated) {
767
873
  clientWs.send(cachedCreated.cdpData);
768
874
  console.log(`[TARGET CREATED EVENT] Sent cached Target.targetCreated to client: ${mapping.clientId}`);
769
- pendingTargetCreatedEvents.delete(targetId);
875
+ ns.pendingTargetCreatedEvents.delete(targetId);
770
876
  }
771
877
 
772
- const cachedEvent = pendingAttachedEvents.get(targetId);
878
+ const cachedEvent = ns.pendingAttachedEvents.get(targetId);
773
879
  if (cachedEvent) {
774
880
  if (cachedEvent.parsed.sessionId) {
775
- sessionToClientId.set(cachedEvent.parsed.sessionId, mapping.clientId);
881
+ ns.sessionToClientId.set(cachedEvent.parsed.sessionId, mapping.clientId);
776
882
  }
777
883
  console.log(`[SESSION MAPPED from cached] sessionId=${cachedEvent.parsed.sessionId?.substring(0,8) || 'none'} -> clientId=${mapping.clientId} (targetId=${targetId})`);
778
- pendingAttachedEvents.delete(targetId);
884
+ ns.pendingAttachedEvents.delete(targetId);
779
885
 
780
886
  const cdpMsg = {
781
887
  method: cachedEvent.parsed.method,
@@ -789,37 +895,33 @@ function handlePluginConnection(ws, clientInfo) {
789
895
  const newTargetInfo = cachedCreated?.parsed?.params?.targetInfo
790
896
  || cachedEvent?.parsed?.params?.targetInfo;
791
897
  if (newTargetInfo) {
792
- const exists = cachedTargets.some(t => t.targetId === targetId);
898
+ const exists = ns.cachedTargets.some(t => t.targetId === targetId);
793
899
  if (!exists) {
794
- cachedTargets.push(newTargetInfo);
900
+ ns.cachedTargets.push(newTargetInfo);
795
901
  }
796
902
  } else {
797
- invalidateTargetsCache();
903
+ invalidateTargetsCache(ws);
798
904
  }
799
905
  }
800
- // 过滤 Target.getTargets 响应,只返回该客户端拥有的 target
801
906
  if (mapping.isGetTargets && parsed.result && parsed.result.targetInfos) {
802
907
  const clientId = mapping.clientId;
803
908
  parsed.result.targetInfos = parsed.result.targetInfos.filter(t => {
804
909
  if (t.type !== 'page') return true;
805
- const ownerClient = targetIdToClientId.get(t.targetId);
910
+ const ownerClient = ns.targetIdToClientId.get(t.targetId);
806
911
  return ownerClient === clientId;
807
912
  });
808
913
  console.log(`[GET TARGETS FILTERED] client=${clientId} returned ${parsed.result.targetInfos.filter(t => t.type === 'page').length} page targets`);
809
914
  }
810
- // 清理 Target.closeTarget 成功后的映射
811
915
  if (parsed.result && parsed.result.success !== undefined && mapping.method === 'Target.closeTarget') {
812
916
  if (mapping.closeTargetId) {
813
- targetIdToClientId.delete(mapping.closeTargetId);
917
+ ns.targetIdToClientId.delete(mapping.closeTargetId);
814
918
  console.log(`[CLOSE TARGET CLEANUP] removed targetId=${mapping.closeTargetId?.substring(0,8)} from mapping`);
815
919
  }
816
- invalidateTargetsCache();
920
+ invalidateTargetsCache(ws);
817
921
  }
818
922
 
819
- // 然后发送响应给客户端
820
923
  const originalId = mapping.originalId;
821
924
  parsed.id = originalId;
822
- // 如果请求有 sessionId,但响应没有,添加 sessionId
823
925
  if (mapping.sessionId && !parsed.sessionId) {
824
926
  parsed.sessionId = mapping.sessionId;
825
927
  }
@@ -837,7 +939,8 @@ function handlePluginConnection(ws, clientInfo) {
837
939
 
838
940
  // 2. sessionId 路由:消息属于特定 session(事件,没有 id)
839
941
  if (parsed && parsed.sessionId) {
840
- const targetClientId = sessionToClientId.get(parsed.sessionId);
942
+ const ns = getNamespace(ws);
943
+ const targetClientId = ns.sessionToClientId.get(parsed.sessionId);
841
944
  console.log(`[SESSION ROUTE] sessionId=${parsed.sessionId?.substring(0,8) || 'none'} -> clientId=${targetClientId || 'not found'}`);
842
945
  if (targetClientId) {
843
946
  const clientWs = clientById.get(targetClientId);
@@ -881,7 +984,6 @@ function handlePluginConnection(ws, clientInfo) {
881
984
  cleanupPlugin(ws, id, `close:${code}`);
882
985
  });
883
986
 
884
- // 错误处理
885
987
  ws.on('error', (error) => {
886
988
  console.error(`[PLUGIN ERROR] ${id}:`, error.message);
887
989
 
@@ -893,6 +995,7 @@ function handlePluginConnection(ws, clientInfo) {
893
995
  });
894
996
 
895
997
  pluginConnections.delete(ws);
998
+ pluginNamespaces.delete(ws);
896
999
 
897
1000
  clientConnections.forEach(clientWs => {
898
1001
  if (clientWs.pairedPlugin === ws) {
@@ -915,6 +1018,7 @@ function handlePluginConnection(ws, clientInfo) {
915
1018
  type: 'connected',
916
1019
  role: 'plugin',
917
1020
  id: id,
1021
+ pluginId: ws.pluginId,
918
1022
  fresh: (Date.now() - SERVER_START_TIME) < 5000,
919
1023
  timestamp: Date.now()
920
1024
  }));
@@ -923,11 +1027,11 @@ function handlePluginConnection(ws, clientInfo) {
923
1027
  /**
924
1028
  * 处理 CDP 客户端连接 (Playwright/Puppeteer)
925
1029
  */
926
- function handleClientConnection(ws, clientInfo, customClientId = null) {
1030
+ function handleClientConnection(ws, clientInfo, customClientId = null, targetPluginId = null) {
927
1031
  clientConnections.add(ws);
928
1032
  const id = customClientId || generateId('client');
929
1033
  if (shouldLog('info')) {
930
- console.log(`\n[CLIENT CONNECTED] ID: ${id}${customClientId ? ' (custom)' : ''}`);
1034
+ console.log(`\n[CLIENT CONNECTED] ID: ${id}${customClientId ? ' (custom)' : ''}${targetPluginId ? ` targetPlugin=${targetPluginId}` : ''}`);
931
1035
  console.log(` - Remote: ${clientInfo.ip}:${clientInfo.port}`);
932
1036
  console.log(` - Total client connections: ${clientConnections.size}`);
933
1037
  }
@@ -940,7 +1044,6 @@ function handleClientConnection(ws, clientInfo, customClientId = null) {
940
1044
  totalClients: clientConnections.size
941
1045
  });
942
1046
 
943
- // 检查是否有可用的 plugin 连接
944
1047
  if (pluginConnections.size === 0) {
945
1048
  if (shouldLog('warn')) {
946
1049
  console.log(` - WARNING: No plugin connections available!`);
@@ -958,27 +1061,36 @@ function handleClientConnection(ws, clientInfo, customClientId = null) {
958
1061
  }
959
1062
  }
960
1063
  } else {
961
- // 多客户端模式: 所有客户端共享同一个 plugin
962
- // 每个 clientId 对应不同的 tab
963
- const pluginWs = pluginConnections.values().next().value;
1064
+ let pluginWs;
1065
+ if (targetPluginId) {
1066
+ pluginWs = [...pluginConnections].find(p => p.pluginId === targetPluginId);
1067
+ if (!pluginWs) {
1068
+ if (shouldLog('warn')) {
1069
+ console.log(` - WARNING: Plugin ${targetPluginId} not found!`);
1070
+ }
1071
+ ws.close(4004, `Plugin ${targetPluginId} not found`);
1072
+ return;
1073
+ }
1074
+ } else {
1075
+ pluginWs = pluginConnections.values().next().value;
1076
+ }
964
1077
  if (pluginWs) {
965
1078
  connectionPairs.set(id, pluginWs);
966
1079
  ws.pairedPlugin = pluginWs;
1080
+ ws.targetPluginId = pluginWs.pluginId;
967
1081
  clientIdToPlugin.set(id, pluginWs);
968
1082
 
969
1083
  if (shouldLog('info')) {
970
- console.log(` - Paired with plugin: ${pluginWs.id} (shared mode)`);
1084
+ console.log(` - Paired with plugin: ${pluginWs.id} (pluginId=${pluginWs.pluginId})`);
971
1085
  }
972
1086
 
973
1087
  logConnectionEvent('CLIENT_PAIRED', { clientId: id, pluginId: pluginWs.id });
974
1088
 
975
- // 通知 Plugin 新客户端已连接
976
1089
  pluginWs.send(JSON.stringify({
977
1090
  type: 'client-connected',
978
1091
  clientId: id
979
1092
  }));
980
1093
 
981
- // 发送当前所有客户端列表
982
1094
  broadcastClientList();
983
1095
  }
984
1096
  }
@@ -1056,8 +1168,10 @@ function handleClientConnection(ws, clientInfo, customClientId = null) {
1056
1168
  }
1057
1169
  if (parsed && parsed.id !== undefined) {
1058
1170
  if (parsed.method === 'Target.closeTarget') {
1171
+ const pluginWs = ws.pairedPlugin;
1172
+ const ns = pluginWs ? getNamespace(pluginWs) : null;
1059
1173
  const targetId = parsed.params?.targetId;
1060
- const ownerClient = targetId ? targetIdToClientId.get(targetId) : null;
1174
+ const ownerClient = (ns && targetId) ? ns.targetIdToClientId.get(targetId) : null;
1061
1175
  if (ownerClient && ownerClient !== id) {
1062
1176
  console.log(`[BLOCKED] ${parsed.method} targetId=${targetId?.substring(0,8)} owner=${ownerClient?.substring(0,8)} requester=${id?.substring(0,8)} — not owner`);
1063
1177
  const errMsg = JSON.stringify({
@@ -1073,8 +1187,10 @@ function handleClientConnection(ws, clientInfo, customClientId = null) {
1073
1187
  currentMapping.closeTargetId = targetId;
1074
1188
  }
1075
1189
  } else if (parsed.method === 'Target.attachToTarget') {
1190
+ const pluginWs = ws.pairedPlugin;
1191
+ const ns = pluginWs ? getNamespace(pluginWs) : null;
1076
1192
  const targetId = parsed.params?.targetId;
1077
- const ownerClient = targetId ? targetIdToClientId.get(targetId) : null;
1193
+ const ownerClient = (ns && targetId) ? ns.targetIdToClientId.get(targetId) : null;
1078
1194
  if (ownerClient && ownerClient !== id) {
1079
1195
  console.log(`[BLOCKED] ${parsed.method} targetId=${targetId?.substring(0,8)} owner=${ownerClient?.substring(0,8)} requester=${id?.substring(0,8)} — not owner`);
1080
1196
  const errMsg = JSON.stringify({
@@ -1166,7 +1282,17 @@ function handlePageConnection(ws, clientInfo, targetId) {
1166
1282
  ws.lastActivityTime = Date.now();
1167
1283
  clientById.set(id, ws);
1168
1284
 
1169
- const plugin = pluginConnections.values().next().value;
1285
+ let plugin = null;
1286
+ for (const p of pluginConnections) {
1287
+ const ns = getNamespace(p);
1288
+ if (ns.targetIdToClientId.has(targetId)) {
1289
+ plugin = p;
1290
+ break;
1291
+ }
1292
+ }
1293
+ if (!plugin) {
1294
+ plugin = pluginConnections.values().next().value;
1295
+ }
1170
1296
  if (plugin && plugin.readyState === WebSocket.OPEN) {
1171
1297
  ws.pairedPlugin = plugin;
1172
1298
  if (shouldLog('info')) {
@@ -1202,7 +1328,7 @@ function handlePageConnection(ws, clientInfo, targetId) {
1202
1328
  // 对于全局 Target 事件,需要广播给所有客户端
1203
1329
  const broadcastEvents = ['Target.targetCreated', 'Target.attachedToTarget', 'Target.targetDestroyed', 'Target.targetInfoChanged'];
1204
1330
  if (broadcastEvents.includes(msg.method)) {
1205
- rewriteBrowserContextId(cdpMsg);
1331
+ rewriteBrowserContextId(cdpMsg, ws.pairedPlugin);
1206
1332
  console.log(`[PLUGIN -> ALL CLIENTS] Broadcasting ${msg.method}`);
1207
1333
  broadcastToClients(JSON.stringify(cdpMsg), null);
1208
1334
  } else {
@@ -1306,23 +1432,24 @@ function handlePageConnection(ws, clientInfo, targetId) {
1306
1432
  * 插件总是报告 'default',但 Playwright 期望自己创建的 context ID
1307
1433
  * 通过 openerId 找到对应的 clientId,再找到该 client 的 browserContextId
1308
1434
  */
1309
- function rewriteBrowserContextId(cdpMsg) {
1435
+ function rewriteBrowserContextId(cdpMsg, pluginWs) {
1310
1436
  const targetInfo = cdpMsg.params?.targetInfo;
1311
1437
  if (!targetInfo || targetInfo.browserContextId !== 'default') {
1312
1438
  return cdpMsg;
1313
1439
  }
1314
1440
 
1441
+ const ns = pluginWs ? getNamespace(pluginWs) : null;
1315
1442
  let clientId = null;
1316
1443
 
1317
- if (targetInfo.openerId) {
1318
- clientId = targetIdToClientId.get(targetInfo.openerId);
1444
+ if (targetInfo.openerId && ns) {
1445
+ clientId = ns.targetIdToClientId.get(targetInfo.openerId);
1319
1446
  }
1320
- if (!clientId && targetInfo.targetId) {
1321
- clientId = targetIdToClientId.get(targetInfo.targetId);
1447
+ if (!clientId && targetInfo.targetId && ns) {
1448
+ clientId = ns.targetIdToClientId.get(targetInfo.targetId);
1322
1449
  }
1323
1450
 
1324
- if (clientId) {
1325
- const contextId = clientIdToBrowserContext.get(clientId);
1451
+ if (clientId && ns) {
1452
+ const contextId = ns.clientIdToBrowserContext.get(clientId);
1326
1453
  if (contextId) {
1327
1454
  console.log(`[CONTEXT REWRITE] targetId=${targetInfo.targetId?.substring(0,8) || 'none'} browserContextId: 'default' -> '${contextId}' (via openerId=${targetInfo.openerId?.substring(0,8) || 'none'}, clientId=${clientId})`);
1328
1455
  targetInfo.browserContextId = contextId;
@@ -1598,6 +1725,13 @@ setInterval(() => {
1598
1725
  };
1599
1726
  });
1600
1727
 
1728
+ let totalSessions = 0;
1729
+ let totalPendingAttach = 0;
1730
+ pluginNamespaces.forEach(ns => {
1731
+ totalSessions += ns.sessionToClientId.size;
1732
+ totalPendingAttach += ns.pendingAttachRequests.size;
1733
+ });
1734
+
1601
1735
  logStatus({
1602
1736
  timestamp: now,
1603
1737
  plugins: pluginConnections.size,
@@ -1607,8 +1741,8 @@ setInterval(() => {
1607
1741
  pairs: connectionPairs.size,
1608
1742
  pluginDetails: pluginList,
1609
1743
  clientDetails: clientList,
1610
- sessions: sessionToClientId.size,
1611
- pendingAttach: pendingAttachRequests.size
1744
+ sessions: totalSessions,
1745
+ pendingAttach: totalPendingAttach
1612
1746
  });
1613
1747
  }, CONFIG.STATUS_PRINT_INTERVAL);
1614
1748