chrome-devtools-mcp-for-extension 0.13.0 → 0.14.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.
@@ -77,12 +77,74 @@ export class McpContext {
77
77
  await context.#init();
78
78
  return context;
79
79
  }
80
+ /**
81
+ * Reinitialize CDP protocol domains after reconnection
82
+ * This ensures proper CDP event handling after browser restart
83
+ */
84
+ async reinitializeCDP() {
85
+ try {
86
+ // Get CDP client from the browser
87
+ const client = await this.browser._client();
88
+ if (!client) {
89
+ this.logger('Warning: Unable to get CDP client for reinitialization');
90
+ return;
91
+ }
92
+ // 1. Enable Target discovery and auto-attach
93
+ // This is critical for multi-target scenarios (iframes, workers, etc.)
94
+ try {
95
+ await client.send('Target.setDiscoverTargets', { discover: true });
96
+ this.logger('CDP: Target discovery enabled');
97
+ }
98
+ catch (err) {
99
+ this.logger('Warning: Failed to enable target discovery:', err);
100
+ }
101
+ try {
102
+ await client.send('Target.setAutoAttach', {
103
+ autoAttach: true,
104
+ waitForDebuggerOnStart: false,
105
+ flatten: true
106
+ });
107
+ this.logger('CDP: Auto-attach configured');
108
+ }
109
+ catch (err) {
110
+ this.logger('Warning: Failed to configure auto-attach:', err);
111
+ }
112
+ // 2. Enable essential CDP domains for each page
113
+ const pages = await this.browser.pages();
114
+ for (const page of pages) {
115
+ try {
116
+ const pageClient = page._client();
117
+ if (pageClient) {
118
+ // Enable Network domain for request interception
119
+ await pageClient.send('Network.enable');
120
+ // Enable Runtime domain for console messages and exceptions
121
+ await pageClient.send('Runtime.enable');
122
+ // Enable Log domain for browser logs
123
+ await pageClient.send('Log.enable');
124
+ this.logger(`CDP domains enabled for page: ${page.url()}`);
125
+ }
126
+ }
127
+ catch (err) {
128
+ this.logger(`Warning: Failed to enable CDP domains for page ${page.url()}:`, err);
129
+ }
130
+ }
131
+ this.logger('CDP protocol reinitialization completed');
132
+ }
133
+ catch (err) {
134
+ this.logger('Error during CDP reinitialization:', err);
135
+ // Don't throw - this is a best-effort operation
136
+ // The browser should still be usable even if CDP setup partially fails
137
+ }
138
+ }
80
139
  /**
81
140
  * Update browser instance after reconnection
82
141
  */
83
142
  async updateBrowser(newBrowser) {
84
143
  this.browser = newBrowser;
85
144
  this.connectionManager.setBrowser(newBrowser, async () => newBrowser);
145
+ // Reinitialize CDP protocol domains BEFORE collectors
146
+ // This ensures CDP events are properly set up
147
+ await this.reinitializeCDP();
86
148
  // Reinitialize collectors for new browser
87
149
  await this.#networkCollector.init();
88
150
  await this.#consoleCollector.init();
@@ -3,6 +3,7 @@
3
3
  * Copyright 2025 Google LLC
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
+ import { ProtocolError, TimeoutError } from 'puppeteer';
6
7
  /**
7
8
  * Default connection manager options
8
9
  */
@@ -13,25 +14,55 @@ const DEFAULT_OPTIONS = {
13
14
  enableLogging: true,
14
15
  onReconnect: undefined,
15
16
  };
17
+ /**
18
+ * Connection state enum
19
+ */
20
+ var ConnectionState;
21
+ (function (ConnectionState) {
22
+ ConnectionState["CONNECTED"] = "CONNECTED";
23
+ ConnectionState["RECONNECTING"] = "RECONNECTING";
24
+ ConnectionState["CLOSED"] = "CLOSED";
25
+ })(ConnectionState || (ConnectionState = {}));
16
26
  /**
17
27
  * Browser Connection Manager
18
28
  *
19
- * Provides automatic reconnection for CDP operations
29
+ * Provides automatic reconnection for CDP operations with:
30
+ * - Single-flight pattern to prevent concurrent reconnections
31
+ * - Event-driven detection via browser 'disconnected' event
32
+ * - State machine tracking (CONNECTED | RECONNECTING | CLOSED)
33
+ * - Exponential backoff with jitter to prevent thundering herd
20
34
  */
21
35
  export class BrowserConnectionManager {
22
36
  reconnectAttempts = 0;
23
37
  options;
24
38
  browser = null;
25
39
  browserFactory = null;
40
+ /** Single-flight pattern: prevents concurrent reconnection attempts */
41
+ reconnectInFlight = null;
42
+ /** Shared reconnection sequence for multiple operations */
43
+ reconnectSequenceInFlight = null;
44
+ /** Current connection state */
45
+ state = ConnectionState.CLOSED;
26
46
  constructor(options = {}) {
27
47
  this.options = { ...DEFAULT_OPTIONS, ...options };
28
48
  }
29
49
  /**
30
50
  * Set browser instance and factory for reconnection
51
+ *
52
+ * @param browser - Browser instance to manage
53
+ * @param factory - Factory function to create new browser instances on reconnection
31
54
  */
32
55
  setBrowser(browser, factory) {
33
56
  this.browser = browser;
34
57
  this.browserFactory = factory;
58
+ this.state = ConnectionState.CONNECTED;
59
+ // Event-driven detection: hook into browser 'disconnected' event
60
+ this.browser.on('disconnected', () => {
61
+ this.log('Browser disconnected event fired');
62
+ this.setState(ConnectionState.CLOSED);
63
+ // Trigger immediate reconnection (will be handled by next operation)
64
+ });
65
+ this.log('Browser instance set, state: CONNECTED');
35
66
  }
36
67
  /**
37
68
  * Execute an operation with automatic retry on CDP connection errors
@@ -49,23 +80,75 @@ export class BrowserConnectionManager {
49
80
  }
50
81
  }
51
82
  /**
52
- * Retry operation with exponential backoff
83
+ * Retry operation with exponential backoff and jitter
84
+ *
85
+ * Implements single-flight pattern at the sequence level:
86
+ * - Multiple concurrent operations share the same reconnection sequence
87
+ * - Only one reconnection sequence runs at a time
88
+ * - All waiting operations retry after the shared sequence completes
89
+ *
90
+ * Adds ±20% randomness to retry delays to prevent thundering herd problem.
91
+ *
92
+ * @param operation - Operation to retry
93
+ * @param operationName - Name of operation for logging
94
+ * @returns Result of operation
53
95
  */
54
96
  async retryWithReconnect(operation, operationName) {
97
+ // Single-flight pattern: if a reconnection sequence is already running,
98
+ // wait for it to complete, then try the operation once
99
+ if (this.reconnectSequenceInFlight) {
100
+ this.log(`${operationName}: Waiting for ongoing reconnection sequence...`);
101
+ try {
102
+ await this.reconnectSequenceInFlight;
103
+ this.log(`${operationName}: Reconnection sequence completed, retrying operation...`);
104
+ return await operation();
105
+ }
106
+ catch (error) {
107
+ // Reconnection sequence failed, propagate the error
108
+ throw error;
109
+ }
110
+ }
111
+ // Start a new reconnection sequence
112
+ this.reconnectSequenceInFlight = this._runReconnectionSequence();
113
+ try {
114
+ await this.reconnectSequenceInFlight;
115
+ this.log(`${operationName}: Reconnection sequence completed, retrying operation...`);
116
+ return await operation();
117
+ }
118
+ catch (error) {
119
+ throw error;
120
+ }
121
+ finally {
122
+ // Clear the in-flight sequence
123
+ this.reconnectSequenceInFlight = null;
124
+ }
125
+ }
126
+ /**
127
+ * Run a full reconnection sequence with exponential backoff
128
+ *
129
+ * This is the actual retry loop that multiple operations can share.
130
+ *
131
+ * @private
132
+ */
133
+ async _runReconnectionSequence() {
55
134
  const maxAttempts = this.options.maxReconnectAttempts ?? 3;
56
135
  const initialDelay = this.options.initialRetryDelay ?? 1000;
57
136
  const maxDelay = this.options.maxRetryDelay ?? 10000;
58
137
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
59
138
  this.reconnectAttempts++;
60
139
  const attemptNum = attempt + 1;
61
- this.log(`Reconnect attempt ${attemptNum}/${maxAttempts} for ${operationName}...`);
140
+ this.log(`Reconnect attempt ${attemptNum}/${maxAttempts}...`);
62
141
  // Exponential backoff with max delay
63
- const delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
142
+ const baseDelay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
143
+ // Add jitter: ±20% randomness to prevent thundering herd
144
+ const jitter = baseDelay * 0.2 * (Math.random() * 2 - 1);
145
+ const delay = Math.max(0, baseDelay + jitter);
146
+ this.log(`Waiting ${delay.toFixed(0)}ms before reconnect attempt...`);
64
147
  await this.sleep(delay);
65
148
  try {
66
149
  await this.reconnectBrowser();
67
- this.log(`Reconnection successful, retrying ${operationName}...`);
68
- return await operation();
150
+ this.log(`Reconnection successful`);
151
+ return; // Success!
69
152
  }
70
153
  catch (error) {
71
154
  if (attempt === maxAttempts - 1) {
@@ -79,8 +162,19 @@ export class BrowserConnectionManager {
79
162
  }
80
163
  /**
81
164
  * Check if error is a CDP connection error
165
+ *
166
+ * Uses type-safe error detection with instanceof checks and falls back
167
+ * to string matching for compatibility.
168
+ *
169
+ * @param error - Error to check
170
+ * @returns true if error is a CDP connection error
82
171
  */
83
172
  isCDPConnectionError(error) {
173
+ // Type-safe detection using instanceof
174
+ if (error instanceof ProtocolError || error instanceof TimeoutError) {
175
+ return true;
176
+ }
177
+ // Fallback: string matching for error messages
84
178
  const errorMessage = error?.message || '';
85
179
  return (errorMessage.includes('Target closed') ||
86
180
  errorMessage.includes('Protocol error') ||
@@ -89,12 +183,39 @@ export class BrowserConnectionManager {
89
183
  errorMessage.includes('WebSocket is not open'));
90
184
  }
91
185
  /**
92
- * Reconnect browser instance
186
+ * Reconnect browser instance with single-flight pattern
187
+ *
188
+ * Ensures only one reconnection attempt happens at a time.
189
+ * Multiple concurrent calls will wait for the same reconnection promise.
190
+ *
191
+ * @returns Promise that resolves when reconnection is complete
93
192
  */
94
193
  async reconnectBrowser() {
194
+ // Single-flight pattern: return existing promise if reconnection is already in progress
195
+ if (this.reconnectInFlight) {
196
+ this.log('Reconnection already in progress, waiting...');
197
+ return this.reconnectInFlight;
198
+ }
199
+ // Create new reconnection promise
200
+ this.reconnectInFlight = this._doReconnect();
201
+ try {
202
+ await this.reconnectInFlight;
203
+ }
204
+ finally {
205
+ // Clear in-flight promise when complete
206
+ this.reconnectInFlight = null;
207
+ }
208
+ }
209
+ /**
210
+ * Internal reconnection logic
211
+ *
212
+ * @private
213
+ */
214
+ async _doReconnect() {
95
215
  if (!this.browserFactory) {
96
216
  throw new Error('Browser factory not set. Cannot reconnect.');
97
217
  }
218
+ this.setState(ConnectionState.RECONNECTING);
98
219
  try {
99
220
  // Close old browser if still connected
100
221
  if (this.browser?.isConnected()) {
@@ -109,6 +230,12 @@ export class BrowserConnectionManager {
109
230
  // Create new browser instance
110
231
  const newBrowser = await this.browserFactory();
111
232
  this.browser = newBrowser;
233
+ // Re-attach disconnected event handler
234
+ this.browser.on('disconnected', () => {
235
+ this.log('Browser disconnected event fired');
236
+ this.setState(ConnectionState.CLOSED);
237
+ });
238
+ this.setState(ConnectionState.CONNECTED);
112
239
  this.log('Browser reconnected successfully');
113
240
  // Notify callback if provided
114
241
  if (this.options.onReconnect) {
@@ -138,8 +265,21 @@ ${lastError?.message || 'Unknown error'}
138
265
  error.name = 'CDPReconnectionError';
139
266
  return error;
140
267
  }
268
+ /**
269
+ * Set connection state and log transition
270
+ *
271
+ * @param newState - New connection state
272
+ */
273
+ setState(newState) {
274
+ if (this.state !== newState) {
275
+ this.log(`State transition: ${this.state} -> ${newState}`);
276
+ this.state = newState;
277
+ }
278
+ }
141
279
  /**
142
280
  * Log message if logging is enabled
281
+ *
282
+ * @param message - Message to log
143
283
  */
144
284
  log(message) {
145
285
  if (this.options.enableLogging !== false) {
@@ -172,10 +312,28 @@ ${lastError?.message || 'Unknown error'}
172
312
  }
173
313
  /**
174
314
  * Check if browser is currently connected
315
+ *
316
+ * @returns true if browser is connected
175
317
  */
176
318
  isConnected() {
177
319
  return this.browser?.isConnected() ?? false;
178
320
  }
321
+ /**
322
+ * Get current connection state
323
+ *
324
+ * @returns Current connection state (CONNECTED | RECONNECTING | CLOSED)
325
+ */
326
+ getState() {
327
+ return this.state;
328
+ }
329
+ /**
330
+ * Check if reconnection is currently in progress
331
+ *
332
+ * @returns true if reconnection is in progress
333
+ */
334
+ isReconnecting() {
335
+ return this.state === ConnectionState.RECONNECTING;
336
+ }
179
337
  }
180
338
  /**
181
339
  * Global connection manager instance
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-devtools-mcp-for-extension",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "MCP server for Chrome extension development with Web Store automation. Fork of chrome-devtools-mcp with extension-specific tools.",
5
5
  "type": "module",
6
6
  "bin": "./build/src/index.js",