This is page 2 of 3. Use http://codebase.md/weotzi/browser-tools-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .DS_Store ├── .gitignore ├── browser-tools-mcp │ ├── mcp-server.ts │ ├── package-lock.json │ ├── package.json │ ├── README.md │ └── tsconfig.json ├── browser-tools-server │ ├── browser-connector.ts │ ├── lighthouse │ │ ├── accessibility.ts │ │ ├── best-practices.ts │ │ ├── index.ts │ │ ├── performance.ts │ │ ├── seo.ts │ │ └── types.ts │ ├── package-lock.json │ ├── package.json │ ├── puppeteer-service.ts │ ├── README.md │ └── tsconfig.json ├── chrome-extension │ ├── background.js │ ├── devtools.html │ ├── devtools.js │ ├── manifest.json │ ├── panel.html │ └── panel.js ├── docs │ ├── mcp-docs.md │ └── mcp.md ├── LICENSE └── README.md ``` # Files -------------------------------------------------------------------------------- /browser-tools-server/puppeteer-service.ts: -------------------------------------------------------------------------------- ```typescript 1 | import fs from "fs"; 2 | import puppeteer from "puppeteer-core"; 3 | import path from "path"; 4 | import os from "os"; 5 | import { execSync } from "child_process"; 6 | import * as ChromeLauncher from "chrome-launcher"; 7 | // ===== Configuration Types and Defaults ===== 8 | 9 | /** 10 | * Configuration interface for the Puppeteer service 11 | */ 12 | export interface PuppeteerServiceConfig { 13 | // Browser preferences 14 | preferredBrowsers?: string[]; // Order of browser preference ("chrome", "edge", "brave", "firefox") 15 | customBrowserPaths?: { [key: string]: string }; // Custom browser executable paths 16 | 17 | // Connection settings 18 | debugPorts?: number[]; // Ports to try when connecting to existing browsers 19 | connectionTimeout?: number; // Timeout for connection attempts in ms 20 | maxRetries?: number; // Maximum number of retries for connections 21 | 22 | // Browser cleanup settings 23 | browserCleanupTimeout?: number; // Timeout before closing inactive browsers (ms) 24 | 25 | // Performance settings 26 | blockResourceTypes?: string[]; // Resource types to block for performance 27 | } 28 | 29 | // Default configuration values 30 | const DEFAULT_CONFIG: PuppeteerServiceConfig = { 31 | preferredBrowsers: ["chrome", "edge", "brave", "firefox"], 32 | debugPorts: [9222, 9223, 9224, 9225], 33 | connectionTimeout: 10000, 34 | maxRetries: 3, 35 | browserCleanupTimeout: 60000, 36 | blockResourceTypes: ["image", "font", "media"], 37 | }; 38 | 39 | // Browser support notes: 40 | // - Chrome/Chromium: Fully supported (primary target) 41 | // - Edge: Fully supported (Chromium-based) 42 | // - Brave: Fully supported (Chromium-based) 43 | // - Firefox: Partially supported (some features may not work) 44 | // - Safari: Not supported by Puppeteer 45 | 46 | // ===== Global State ===== 47 | 48 | // Current active configuration 49 | let currentConfig: PuppeteerServiceConfig = { ...DEFAULT_CONFIG }; 50 | 51 | // Browser instance management 52 | let headlessBrowserInstance: puppeteer.Browser | null = null; 53 | let launchedBrowserWSEndpoint: string | null = null; 54 | 55 | // Cleanup management 56 | let browserCleanupTimeout: NodeJS.Timeout | null = null; 57 | let BROWSER_CLEANUP_TIMEOUT = 60000; // 60 seconds default 58 | 59 | // Cache for browser executable paths 60 | let detectedBrowserPath: string | null = null; 61 | 62 | // ===== Configuration Functions ===== 63 | 64 | /** 65 | * Configure the Puppeteer service with custom settings 66 | * @param config Partial configuration to override defaults 67 | */ 68 | export function configurePuppeteerService( 69 | config: Partial<PuppeteerServiceConfig> 70 | ): void { 71 | currentConfig = { ...DEFAULT_CONFIG, ...config }; 72 | 73 | // Update the timeout if it was changed 74 | if ( 75 | config.browserCleanupTimeout && 76 | config.browserCleanupTimeout !== BROWSER_CLEANUP_TIMEOUT 77 | ) { 78 | BROWSER_CLEANUP_TIMEOUT = config.browserCleanupTimeout; 79 | } 80 | 81 | console.log("Puppeteer service configured:", currentConfig); 82 | } 83 | 84 | // ===== Browser Management ===== 85 | 86 | /** 87 | * Get or create a headless browser instance 88 | * @returns Promise resolving to a browser instance 89 | */ 90 | async function getHeadlessBrowserInstance(): Promise<puppeteer.Browser> { 91 | console.log("Browser instance request started"); 92 | 93 | // Cancel any scheduled cleanup 94 | cancelScheduledCleanup(); 95 | 96 | // Try to reuse existing browser 97 | if (headlessBrowserInstance) { 98 | try { 99 | const pages = await headlessBrowserInstance.pages(); 100 | console.log( 101 | `Reusing existing headless browser with ${pages.length} pages` 102 | ); 103 | return headlessBrowserInstance; 104 | } catch (error) { 105 | console.log( 106 | "Existing browser instance is no longer valid, creating a new one" 107 | ); 108 | headlessBrowserInstance = null; 109 | launchedBrowserWSEndpoint = null; 110 | } 111 | } 112 | 113 | // Create a new browser instance 114 | return launchNewBrowser(); 115 | } 116 | 117 | /** 118 | * Launches a new browser instance 119 | * @returns Promise resolving to a browser instance 120 | */ 121 | async function launchNewBrowser(): Promise<puppeteer.Browser> { 122 | console.log("Creating new headless browser instance"); 123 | 124 | // Setup temporary user data directory 125 | const userDataDir = createTempUserDataDir(); 126 | let browser: puppeteer.Browser | null = null; 127 | 128 | try { 129 | // Configure launch options 130 | const launchOptions = configureLaunchOptions(userDataDir); 131 | 132 | // Set custom browser executable 133 | await setCustomBrowserExecutable(launchOptions); 134 | 135 | // Launch the browser 136 | console.log( 137 | "Launching browser with options:", 138 | JSON.stringify({ 139 | headless: launchOptions.headless, 140 | executablePath: launchOptions.executablePath, 141 | }) 142 | ); 143 | 144 | browser = await puppeteer.launch(launchOptions); 145 | 146 | // Store references to the browser instance 147 | launchedBrowserWSEndpoint = browser.wsEndpoint(); 148 | headlessBrowserInstance = browser; 149 | 150 | // Setup cleanup handlers 151 | setupBrowserCleanupHandlers(browser, userDataDir); 152 | 153 | console.log("Browser ready"); 154 | return browser; 155 | } catch (error) { 156 | console.error("Failed to launch browser:", error); 157 | 158 | // Clean up resources 159 | if (browser) { 160 | try { 161 | await browser.close(); 162 | } catch (closeError) { 163 | console.error("Error closing browser:", closeError); 164 | } 165 | headlessBrowserInstance = null; 166 | launchedBrowserWSEndpoint = null; 167 | } 168 | 169 | // Clean up the temporary directory 170 | try { 171 | fs.rmSync(userDataDir, { recursive: true, force: true }); 172 | } catch (fsError) { 173 | console.error("Error removing temporary directory:", fsError); 174 | } 175 | 176 | throw error; 177 | } 178 | } 179 | 180 | /** 181 | * Creates a temporary user data directory for the browser 182 | * @returns Path to the created directory 183 | */ 184 | function createTempUserDataDir(): string { 185 | const tempDir = os.tmpdir(); 186 | const uniqueId = `${Date.now().toString()}-${Math.random() 187 | .toString(36) 188 | .substring(2)}`; 189 | const userDataDir = path.join(tempDir, `browser-debug-profile-${uniqueId}`); 190 | fs.mkdirSync(userDataDir, { recursive: true }); 191 | console.log(`Using temporary user data directory: ${userDataDir}`); 192 | return userDataDir; 193 | } 194 | 195 | /** 196 | * Configures browser launch options 197 | * @param userDataDir Path to the user data directory 198 | * @returns Launch options object 199 | */ 200 | function configureLaunchOptions(userDataDir: string): any { 201 | const launchOptions: any = { 202 | args: [ 203 | "--remote-debugging-port=0", // Use dynamic port 204 | `--user-data-dir=${userDataDir}`, 205 | "--no-first-run", 206 | "--no-default-browser-check", 207 | "--disable-dev-shm-usage", 208 | "--disable-extensions", 209 | "--disable-component-extensions-with-background-pages", 210 | "--disable-background-networking", 211 | "--disable-backgrounding-occluded-windows", 212 | "--disable-default-apps", 213 | "--disable-sync", 214 | "--disable-translate", 215 | "--metrics-recording-only", 216 | "--no-pings", 217 | "--safebrowsing-disable-auto-update", 218 | ], 219 | }; 220 | 221 | // Add headless mode (using any to bypass type checking issues) 222 | launchOptions.headless = "new"; 223 | 224 | return launchOptions; 225 | } 226 | 227 | /** 228 | * Sets a custom browser executable path if configured 229 | * @param launchOptions Launch options object to modify 230 | */ 231 | async function setCustomBrowserExecutable(launchOptions: any): Promise<void> { 232 | // First, try to use a custom browser path from configuration 233 | if ( 234 | currentConfig.customBrowserPaths && 235 | Object.keys(currentConfig.customBrowserPaths).length > 0 236 | ) { 237 | const preferredBrowsers = currentConfig.preferredBrowsers || [ 238 | "chrome", 239 | "edge", 240 | "brave", 241 | "firefox", 242 | ]; 243 | 244 | for (const browser of preferredBrowsers) { 245 | if ( 246 | currentConfig.customBrowserPaths[browser] && 247 | fs.existsSync(currentConfig.customBrowserPaths[browser]) 248 | ) { 249 | launchOptions.executablePath = 250 | currentConfig.customBrowserPaths[browser]; 251 | 252 | // Set product to firefox if using Firefox browser 253 | if (browser === "firefox") { 254 | launchOptions.product = "firefox"; 255 | } 256 | 257 | console.log( 258 | `Using custom ${browser} path: ${launchOptions.executablePath}` 259 | ); 260 | return; 261 | } 262 | } 263 | } 264 | 265 | // If no custom path is found, use cached path or detect a new one 266 | try { 267 | if (detectedBrowserPath && fs.existsSync(detectedBrowserPath)) { 268 | console.log(`Using cached browser path: ${detectedBrowserPath}`); 269 | launchOptions.executablePath = detectedBrowserPath; 270 | 271 | // Check if the detected browser is Firefox 272 | if (detectedBrowserPath.includes("firefox")) { 273 | launchOptions.product = "firefox"; 274 | console.log("Setting product to firefox for Firefox browser"); 275 | } 276 | } else { 277 | detectedBrowserPath = await findBrowserExecutablePath(); 278 | launchOptions.executablePath = detectedBrowserPath; 279 | 280 | // Check if the detected browser is Firefox 281 | if (detectedBrowserPath.includes("firefox")) { 282 | launchOptions.product = "firefox"; 283 | console.log("Setting product to firefox for Firefox browser"); 284 | } 285 | 286 | console.log( 287 | `Using detected browser path: ${launchOptions.executablePath}` 288 | ); 289 | } 290 | } catch (error) { 291 | console.error("Failed to detect browser executable path:", error); 292 | throw new Error( 293 | "No browser executable path found. Please specify a custom browser path in the configuration." 294 | ); 295 | } 296 | } 297 | 298 | /** 299 | * Find a browser executable path on the current system 300 | * @returns Path to a browser executable 301 | */ 302 | async function findBrowserExecutablePath(): Promise<string> { 303 | // Try to use chrome-launcher (most reliable method) 304 | try { 305 | console.log("Attempting to find Chrome using chrome-launcher..."); 306 | 307 | // Launch Chrome using chrome-launcher 308 | const chrome = await ChromeLauncher.launch({ 309 | chromeFlags: ["--headless"], 310 | handleSIGINT: false, 311 | }); 312 | 313 | // chrome-launcher stores the Chrome executable path differently than Puppeteer 314 | // Let's try different approaches to get it 315 | 316 | // First check if we can access it directly 317 | let chromePath = ""; 318 | 319 | // Chrome version data often contains the path 320 | if (chrome.process && chrome.process.spawnfile) { 321 | chromePath = chrome.process.spawnfile; 322 | console.log("Found Chrome path from process.spawnfile"); 323 | } else { 324 | // Try to get the Chrome path from chrome-launcher 325 | // In newer versions, it's directly accessible 326 | console.log("Trying to determine Chrome path using other methods"); 327 | 328 | // This will actually return the real Chrome path for us 329 | // chrome-launcher has this inside but doesn't expose it directly 330 | const possiblePaths = [ 331 | process.env.CHROME_PATH, 332 | // Common paths by OS 333 | ...(process.platform === "darwin" 334 | ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"] 335 | : process.platform === "win32" 336 | ? [ 337 | `${process.env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`, 338 | `${process.env["PROGRAMFILES(X86)"]}\\Google\\Chrome\\Application\\chrome.exe`, 339 | ] 340 | : ["/usr/bin/google-chrome"]), 341 | ].filter(Boolean); 342 | 343 | // Use the first valid path 344 | for (const p of possiblePaths) { 345 | if (p && fs.existsSync(p)) { 346 | chromePath = p; 347 | console.log("Found Chrome path from common locations"); 348 | break; 349 | } 350 | } 351 | } 352 | 353 | // Always kill the Chrome instance we just launched 354 | await chrome.kill(); 355 | 356 | if (chromePath) { 357 | console.log(`Chrome found via chrome-launcher: ${chromePath}`); 358 | return chromePath; 359 | } else { 360 | console.log("Chrome launched but couldn't determine executable path"); 361 | } 362 | } catch (error) { 363 | // Check if it's a ChromeNotInstalledError 364 | const errorMessage = error instanceof Error ? error.message : String(error); 365 | if ( 366 | errorMessage.includes("No Chrome installations found") || 367 | (error as any)?.code === "ERR_LAUNCHER_NOT_INSTALLED" 368 | ) { 369 | console.log("Chrome not installed. Falling back to manual detection"); 370 | } else { 371 | console.error("Failed to find Chrome using chrome-launcher:", error); 372 | console.log("Falling back to manual detection"); 373 | } 374 | } 375 | 376 | // If chrome-launcher failed, use manual detection 377 | 378 | const platform = process.platform; 379 | const preferredBrowsers = currentConfig.preferredBrowsers || [ 380 | "chrome", 381 | "edge", 382 | "brave", 383 | "firefox", 384 | ]; 385 | 386 | console.log(`Attempting to detect browser executable path on ${platform}...`); 387 | 388 | // Platform-specific detection strategies 389 | if (platform === "win32") { 390 | // Windows - try registry detection for Chrome 391 | let registryPath = null; 392 | try { 393 | console.log("Checking Windows registry for Chrome..."); 394 | // Try HKLM first 395 | const regOutput = execSync( 396 | 'reg query "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe" /ve', 397 | { encoding: "utf8" } 398 | ); 399 | 400 | // Extract path from registry output 401 | const match = regOutput.match(/REG_(?:SZ|EXPAND_SZ)\s+([^\s]+)/i); 402 | if (match && match[1]) { 403 | registryPath = match[1].replace(/\\"/g, ""); 404 | // Verify the path exists 405 | if (fs.existsSync(registryPath)) { 406 | console.log(`Found Chrome via HKLM registry: ${registryPath}`); 407 | return registryPath; 408 | } 409 | } 410 | } catch (e) { 411 | // Try HKCU if HKLM fails 412 | try { 413 | console.log("Checking user registry for Chrome..."); 414 | const regOutput = execSync( 415 | 'reg query "HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe" /ve', 416 | { encoding: "utf8" } 417 | ); 418 | 419 | // Extract path from registry output 420 | const match = regOutput.match(/REG_(?:SZ|EXPAND_SZ)\s+([^\s]+)/i); 421 | if (match && match[1]) { 422 | registryPath = match[1].replace(/\\"/g, ""); 423 | // Verify the path exists 424 | if (fs.existsSync(registryPath)) { 425 | console.log(`Found Chrome via HKCU registry: ${registryPath}`); 426 | return registryPath; 427 | } 428 | } 429 | } catch (innerError) { 430 | console.log( 431 | "Failed to find Chrome via registry, continuing with path checks" 432 | ); 433 | } 434 | } 435 | 436 | // Try to find Chrome through BLBeacon registry key (version info) 437 | try { 438 | console.log("Checking Chrome BLBeacon registry..."); 439 | const regOutput = execSync( 440 | 'reg query "HKEY_CURRENT_USER\\Software\\Google\\Chrome\\BLBeacon" /v version', 441 | { encoding: "utf8" } 442 | ); 443 | 444 | if (regOutput) { 445 | // If BLBeacon exists, Chrome is likely installed in the default location 446 | const programFiles = process.env.PROGRAMFILES || "C:\\Program Files"; 447 | const programFilesX86 = 448 | process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)"; 449 | 450 | const defaultChromePaths = [ 451 | path.join(programFiles, "Google\\Chrome\\Application\\chrome.exe"), 452 | path.join(programFilesX86, "Google\\Chrome\\Application\\chrome.exe"), 453 | ]; 454 | 455 | for (const chromePath of defaultChromePaths) { 456 | if (fs.existsSync(chromePath)) { 457 | console.log( 458 | `Found Chrome via BLBeacon registry hint: ${chromePath}` 459 | ); 460 | return chromePath; 461 | } 462 | } 463 | } 464 | } catch (e) { 465 | console.log("Failed to find Chrome via BLBeacon registry"); 466 | } 467 | 468 | // Continue with regular path checks 469 | const programFiles = process.env.PROGRAMFILES || "C:\\Program Files"; 470 | const programFilesX86 = 471 | process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)"; 472 | 473 | // Common Windows browser paths 474 | const winBrowserPaths = { 475 | chrome: [ 476 | path.join(programFiles, "Google\\Chrome\\Application\\chrome.exe"), 477 | path.join(programFilesX86, "Google\\Chrome\\Application\\chrome.exe"), 478 | ], 479 | edge: [ 480 | path.join(programFiles, "Microsoft\\Edge\\Application\\msedge.exe"), 481 | path.join(programFilesX86, "Microsoft\\Edge\\Application\\msedge.exe"), 482 | ], 483 | brave: [ 484 | path.join( 485 | programFiles, 486 | "BraveSoftware\\Brave-Browser\\Application\\brave.exe" 487 | ), 488 | path.join( 489 | programFilesX86, 490 | "BraveSoftware\\Brave-Browser\\Application\\brave.exe" 491 | ), 492 | ], 493 | firefox: [ 494 | path.join(programFiles, "Mozilla Firefox\\firefox.exe"), 495 | path.join(programFilesX86, "Mozilla Firefox\\firefox.exe"), 496 | ], 497 | }; 498 | 499 | // Check each browser in preferred order 500 | for (const browser of preferredBrowsers) { 501 | const paths = 502 | winBrowserPaths[browser as keyof typeof winBrowserPaths] || []; 503 | for (const browserPath of paths) { 504 | if (fs.existsSync(browserPath)) { 505 | console.log(`Found ${browser} at ${browserPath}`); 506 | return browserPath; 507 | } 508 | } 509 | } 510 | } else if (platform === "darwin") { 511 | // macOS browser paths 512 | const macBrowserPaths = { 513 | chrome: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"], 514 | edge: ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"], 515 | brave: ["/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"], 516 | firefox: ["/Applications/Firefox.app/Contents/MacOS/firefox"], 517 | safari: ["/Applications/Safari.app/Contents/MacOS/Safari"], 518 | }; 519 | 520 | // Check each browser in preferred order 521 | for (const browser of preferredBrowsers) { 522 | const paths = 523 | macBrowserPaths[browser as keyof typeof macBrowserPaths] || []; 524 | for (const browserPath of paths) { 525 | if (fs.existsSync(browserPath)) { 526 | console.log(`Found ${browser} at ${browserPath}`); 527 | // Safari is detected but not supported by Puppeteer 528 | if (browser === "safari") { 529 | console.log( 530 | "Safari detected but not supported by Puppeteer. Continuing search..." 531 | ); 532 | continue; 533 | } 534 | return browserPath; 535 | } 536 | } 537 | } 538 | } else if (platform === "linux") { 539 | // Linux browser commands 540 | const linuxBrowserCommands = { 541 | chrome: ["google-chrome", "chromium", "chromium-browser"], 542 | edge: ["microsoft-edge"], 543 | brave: ["brave-browser"], 544 | firefox: ["firefox"], 545 | }; 546 | 547 | // Check each browser in preferred order 548 | for (const browser of preferredBrowsers) { 549 | const commands = 550 | linuxBrowserCommands[browser as keyof typeof linuxBrowserCommands] || 551 | []; 552 | for (const cmd of commands) { 553 | try { 554 | // Use more universal commands for Linux to find executables 555 | // command -v works in most shells, fallback to which or type 556 | const browserPath = execSync( 557 | `command -v ${cmd} || which ${cmd} || type -p ${cmd} 2>/dev/null`, 558 | { encoding: "utf8" } 559 | ).trim(); 560 | 561 | if (browserPath && fs.existsSync(browserPath)) { 562 | console.log(`Found ${browser} at ${browserPath}`); 563 | return browserPath; 564 | } 565 | } catch (e) { 566 | // Command not found, continue to next 567 | } 568 | } 569 | } 570 | 571 | // Additional check for unusual locations on Linux 572 | const alternativeLocations = [ 573 | "/usr/bin/google-chrome", 574 | "/usr/bin/chromium", 575 | "/usr/bin/chromium-browser", 576 | "/snap/bin/chromium", 577 | "/snap/bin/google-chrome", 578 | "/opt/google/chrome/chrome", 579 | ]; 580 | 581 | for (const location of alternativeLocations) { 582 | if (fs.existsSync(location)) { 583 | console.log(`Found browser at alternative location: ${location}`); 584 | return location; 585 | } 586 | } 587 | } 588 | 589 | throw new Error( 590 | `No browser executable found for platform ${platform}. Please specify a custom browser path.` 591 | ); 592 | } 593 | 594 | /** 595 | * Sets up cleanup handlers for the browser instance 596 | * @param browser Browser instance 597 | * @param userDataDir Path to the user data directory to clean up 598 | */ 599 | function setupBrowserCleanupHandlers( 600 | browser: puppeteer.Browser, 601 | userDataDir: string 602 | ): void { 603 | browser.on("disconnected", () => { 604 | console.log(`Browser disconnected. Scheduling cleanup for: ${userDataDir}`); 605 | 606 | // Clear any existing cleanup timeout when browser is disconnected 607 | cancelScheduledCleanup(); 608 | 609 | // Delayed cleanup to avoid conflicts with potential new browser instances 610 | setTimeout(() => { 611 | // Only remove the directory if no new browser has been launched 612 | if (!headlessBrowserInstance) { 613 | console.log(`Cleaning up temporary directory: ${userDataDir}`); 614 | try { 615 | fs.rmSync(userDataDir, { recursive: true, force: true }); 616 | console.log(`Successfully removed directory: ${userDataDir}`); 617 | } catch (error) { 618 | console.error(`Failed to remove directory ${userDataDir}:`, error); 619 | } 620 | } else { 621 | console.log( 622 | `Skipping cleanup for ${userDataDir} as new browser instance is active` 623 | ); 624 | } 625 | }, 5000); // 5-second delay for cleanup 626 | 627 | // Reset browser instance variables 628 | launchedBrowserWSEndpoint = null; 629 | headlessBrowserInstance = null; 630 | }); 631 | } 632 | 633 | // ===== Cleanup Management ===== 634 | 635 | /** 636 | * Cancels any scheduled browser cleanup 637 | */ 638 | function cancelScheduledCleanup(): void { 639 | if (browserCleanupTimeout) { 640 | console.log("Cancelling scheduled browser cleanup"); 641 | clearTimeout(browserCleanupTimeout); 642 | browserCleanupTimeout = null; 643 | } 644 | } 645 | 646 | /** 647 | * Schedules automatic cleanup of the browser instance after inactivity 648 | */ 649 | export function scheduleBrowserCleanup(): void { 650 | // Clear any existing timeout first 651 | cancelScheduledCleanup(); 652 | 653 | // Only schedule cleanup if we have an active browser instance 654 | if (headlessBrowserInstance) { 655 | console.log( 656 | `Scheduling browser cleanup in ${BROWSER_CLEANUP_TIMEOUT / 1000} seconds` 657 | ); 658 | 659 | browserCleanupTimeout = setTimeout(() => { 660 | console.log("Executing scheduled browser cleanup"); 661 | if (headlessBrowserInstance) { 662 | console.log("Closing headless browser instance"); 663 | headlessBrowserInstance.close(); 664 | headlessBrowserInstance = null; 665 | launchedBrowserWSEndpoint = null; 666 | } 667 | browserCleanupTimeout = null; 668 | }, BROWSER_CLEANUP_TIMEOUT); 669 | } 670 | } 671 | 672 | // ===== Public Browser Connection API ===== 673 | 674 | /** 675 | * Connects to a headless browser for web operations 676 | * @param url The URL to navigate to 677 | * @param options Connection and emulation options 678 | * @returns Promise resolving to browser, port, and page objects 679 | */ 680 | export async function connectToHeadlessBrowser( 681 | url: string, 682 | options: { 683 | blockResources?: boolean; 684 | customResourceBlockList?: string[]; 685 | emulateDevice?: "mobile" | "tablet" | "desktop"; 686 | emulateNetworkCondition?: "slow3G" | "fast3G" | "4G" | "offline"; 687 | viewport?: { width: number; height: number }; 688 | locale?: string; 689 | timezoneId?: string; 690 | userAgent?: string; 691 | waitForSelector?: string; 692 | waitForTimeout?: number; 693 | cookies?: Array<{ 694 | name: string; 695 | value: string; 696 | domain?: string; 697 | path?: string; 698 | }>; 699 | headers?: Record<string, string>; 700 | } = {} 701 | ): Promise<{ 702 | browser: puppeteer.Browser; 703 | port: number; 704 | page: puppeteer.Page; 705 | }> { 706 | console.log( 707 | `Connecting to headless browser for ${url}${ 708 | options.blockResources ? " (blocking non-essential resources)" : "" 709 | }` 710 | ); 711 | 712 | try { 713 | // Validate URL format 714 | try { 715 | new URL(url); 716 | } catch (e) { 717 | throw new Error(`Invalid URL format: ${url}`); 718 | } 719 | 720 | // Get or create a browser instance 721 | const browser = await getHeadlessBrowserInstance(); 722 | 723 | if (!launchedBrowserWSEndpoint) { 724 | throw new Error("Failed to retrieve WebSocket endpoint for browser"); 725 | } 726 | 727 | // Extract port from WebSocket endpoint 728 | const port = parseInt( 729 | launchedBrowserWSEndpoint.split(":")[2].split("/")[0] 730 | ); 731 | 732 | // Always create a new page for each audit to avoid request interception conflicts 733 | console.log("Creating a new page for this audit"); 734 | const page = await browser.newPage(); 735 | 736 | // Set a longer timeout for navigation 737 | const navigationTimeout = 10000; // 10 seconds 738 | page.setDefaultNavigationTimeout(navigationTimeout); 739 | 740 | // Navigate to the URL 741 | console.log(`Navigating to ${url}`); 742 | await page.goto(url, { 743 | waitUntil: "networkidle2", // Wait until there are no more network connections for at least 500ms 744 | timeout: navigationTimeout, 745 | }); 746 | 747 | // Set custom headers if provided 748 | if (options.headers && Object.keys(options.headers).length > 0) { 749 | await page.setExtraHTTPHeaders(options.headers); 750 | console.log("Set custom HTTP headers"); 751 | } 752 | 753 | // Set cookies if provided 754 | if (options.cookies && options.cookies.length > 0) { 755 | const urlObj = new URL(url); 756 | const cookiesWithDomain = options.cookies.map((cookie) => ({ 757 | ...cookie, 758 | domain: cookie.domain || urlObj.hostname, 759 | path: cookie.path || "/", 760 | })); 761 | await page.setCookie(...cookiesWithDomain); 762 | console.log(`Set ${options.cookies.length} cookies`); 763 | } 764 | 765 | // Set custom viewport if specified 766 | if (options.viewport) { 767 | await page.setViewport(options.viewport); 768 | console.log( 769 | `Set viewport to ${options.viewport.width}x${options.viewport.height}` 770 | ); 771 | } else if (options.emulateDevice) { 772 | // Set common device emulation presets 773 | let viewport; 774 | let userAgent = options.userAgent; 775 | 776 | switch (options.emulateDevice) { 777 | case "mobile": 778 | viewport = { 779 | width: 375, 780 | height: 667, 781 | isMobile: true, 782 | hasTouch: true, 783 | }; 784 | userAgent = 785 | userAgent || 786 | "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X)"; 787 | break; 788 | case "tablet": 789 | viewport = { 790 | width: 768, 791 | height: 1024, 792 | isMobile: true, 793 | hasTouch: true, 794 | }; 795 | userAgent = 796 | userAgent || "Mozilla/5.0 (iPad; CPU OS 13_2_3 like Mac OS X)"; 797 | break; 798 | case "desktop": 799 | default: 800 | viewport = { 801 | width: 1280, 802 | height: 800, 803 | isMobile: false, 804 | hasTouch: false, 805 | }; 806 | break; 807 | } 808 | 809 | await page.setViewport(viewport); 810 | if (userAgent) await page.setUserAgent(userAgent); 811 | 812 | console.log(`Emulating ${options.emulateDevice} device`); 813 | } 814 | 815 | // Set locale and timezone if provided 816 | if (options.locale) { 817 | await page.evaluateOnNewDocument((locale) => { 818 | Object.defineProperty(navigator, "language", { get: () => locale }); 819 | Object.defineProperty(navigator, "languages", { get: () => [locale] }); 820 | }, options.locale); 821 | console.log(`Set locale to ${options.locale}`); 822 | } 823 | 824 | if (options.timezoneId) { 825 | await page.emulateTimezone(options.timezoneId); 826 | console.log(`Set timezone to ${options.timezoneId}`); 827 | } 828 | 829 | // Emulate network conditions if specified 830 | if (options.emulateNetworkCondition) { 831 | // Define network condition types that match puppeteer's expected format 832 | interface PuppeteerNetworkConditions { 833 | offline: boolean; 834 | latency?: number; 835 | download?: number; 836 | upload?: number; 837 | } 838 | 839 | let networkConditions: PuppeteerNetworkConditions; 840 | 841 | switch (options.emulateNetworkCondition) { 842 | case "slow3G": 843 | networkConditions = { 844 | offline: false, 845 | latency: 400, 846 | download: (500 * 1024) / 8, 847 | upload: (500 * 1024) / 8, 848 | }; 849 | break; 850 | case "fast3G": 851 | networkConditions = { 852 | offline: false, 853 | latency: 150, 854 | download: (1.5 * 1024 * 1024) / 8, 855 | upload: (750 * 1024) / 8, 856 | }; 857 | break; 858 | case "4G": 859 | networkConditions = { 860 | offline: false, 861 | latency: 50, 862 | download: (4 * 1024 * 1024) / 8, 863 | upload: (2 * 1024 * 1024) / 8, 864 | }; 865 | break; 866 | case "offline": 867 | networkConditions = { offline: true }; 868 | break; 869 | default: 870 | networkConditions = { offline: false }; 871 | } 872 | 873 | // @ts-ignore - Property might not be in types but is supported 874 | await page.emulateNetworkConditions(networkConditions); 875 | console.log( 876 | `Emulating ${options.emulateNetworkCondition} network conditions` 877 | ); 878 | } 879 | 880 | // Check if we should block resources based on the options 881 | if (options.blockResources) { 882 | const resourceTypesToBlock = options.customResourceBlockList || 883 | currentConfig.blockResourceTypes || ["image", "font", "media"]; 884 | 885 | await page.setRequestInterception(true); 886 | page.on("request", (request) => { 887 | // Block unnecessary resources to speed up loading 888 | const resourceType = request.resourceType(); 889 | if (resourceTypesToBlock.includes(resourceType)) { 890 | request.abort(); 891 | } else { 892 | request.continue(); 893 | } 894 | }); 895 | 896 | console.log( 897 | `Blocking resource types: ${resourceTypesToBlock.join(", ")}` 898 | ); 899 | } 900 | 901 | // Wait for a specific selector if requested 902 | if (options.waitForSelector) { 903 | try { 904 | console.log(`Waiting for selector: ${options.waitForSelector}`); 905 | await page.waitForSelector(options.waitForSelector, { 906 | timeout: options.waitForTimeout || 30000, 907 | }); 908 | } catch (selectorError: any) { 909 | console.warn( 910 | `Failed to find selector "${options.waitForSelector}": ${selectorError.message}` 911 | ); 912 | // Continue anyway, don't fail the whole operation 913 | } 914 | } 915 | 916 | return { browser, port, page }; 917 | } catch (error) { 918 | console.error("Failed to connect to headless browser:", error); 919 | throw new Error( 920 | `Failed to connect to headless browser: ${ 921 | error instanceof Error ? error.message : String(error) 922 | }` 923 | ); 924 | } 925 | } 926 | ``` -------------------------------------------------------------------------------- /chrome-extension/panel.js: -------------------------------------------------------------------------------- ```javascript 1 | // Store settings 2 | let settings = { 3 | logLimit: 50, 4 | queryLimit: 30000, 5 | stringSizeLimit: 500, 6 | showRequestHeaders: false, 7 | showResponseHeaders: false, 8 | maxLogSize: 20000, 9 | screenshotPath: "", 10 | // Add server connection settings 11 | serverHost: "localhost", 12 | serverPort: 3025, 13 | allowAutoPaste: false, // Default auto-paste setting 14 | }; 15 | 16 | // Track connection status 17 | let serverConnected = false; 18 | let reconnectAttemptTimeout = null; 19 | // Add a flag to track ongoing discovery operations 20 | let isDiscoveryInProgress = false; 21 | // Add an AbortController to cancel fetch operations 22 | let discoveryController = null; 23 | 24 | // Load saved settings on startup 25 | chrome.storage.local.get(["browserConnectorSettings"], (result) => { 26 | if (result.browserConnectorSettings) { 27 | settings = { ...settings, ...result.browserConnectorSettings }; 28 | updateUIFromSettings(); 29 | } 30 | 31 | // Create connection status banner at the top 32 | createConnectionBanner(); 33 | 34 | // Automatically discover server on panel load with quiet mode enabled 35 | discoverServer(true); 36 | }); 37 | 38 | // Add listener for connection status updates from background script (page refresh events) 39 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 40 | if (message.type === "CONNECTION_STATUS_UPDATE") { 41 | console.log( 42 | `Received connection status update: ${ 43 | message.isConnected ? "Connected" : "Disconnected" 44 | }` 45 | ); 46 | 47 | // Update UI based on connection status 48 | if (message.isConnected) { 49 | // If already connected, just maintain the current state 50 | if (!serverConnected) { 51 | // Connection was re-established, update UI 52 | serverConnected = true; 53 | updateConnectionBanner(true, { 54 | name: "Browser Tools Server", 55 | version: "reconnected", 56 | host: settings.serverHost, 57 | port: settings.serverPort, 58 | }); 59 | } 60 | } else { 61 | // Connection lost, update UI to show disconnected 62 | serverConnected = false; 63 | updateConnectionBanner(false, null); 64 | } 65 | } 66 | 67 | if (message.type === "INITIATE_AUTO_DISCOVERY") { 68 | console.log( 69 | `Initiating auto-discovery after page refresh (reason: ${message.reason})` 70 | ); 71 | 72 | // For page refreshes or if forceRestart is set to true, always cancel any ongoing discovery and restart 73 | if (message.reason === "page_refresh" || message.forceRestart === true) { 74 | // Cancel any ongoing discovery operation 75 | cancelOngoingDiscovery(); 76 | 77 | // Update UI to indicate we're starting a fresh scan 78 | if (connectionStatusDiv) { 79 | connectionStatusDiv.style.display = "block"; 80 | if (statusIcon) statusIcon.className = "status-indicator"; 81 | if (statusText) 82 | statusText.textContent = 83 | "Page refreshed. Restarting server discovery..."; 84 | } 85 | 86 | // Always update the connection banner when a page refresh occurs 87 | updateConnectionBanner(false, null); 88 | 89 | // Start a new discovery process with quiet mode 90 | console.log("Starting fresh discovery after page refresh"); 91 | discoverServer(true); 92 | } 93 | // For other types of auto-discovery requests, only start if not already in progress 94 | else if (!isDiscoveryInProgress) { 95 | // Use quiet mode for auto-discovery to minimize UI changes 96 | discoverServer(true); 97 | } 98 | } 99 | 100 | // Handle successful server validation 101 | if (message.type === "SERVER_VALIDATION_SUCCESS") { 102 | console.log( 103 | `Server validation successful: ${message.serverHost}:${message.serverPort}` 104 | ); 105 | 106 | // Update the connection status banner 107 | serverConnected = true; 108 | updateConnectionBanner(true, message.serverInfo); 109 | 110 | // If we were showing the connection status dialog, we can hide it now 111 | if (connectionStatusDiv && connectionStatusDiv.style.display === "block") { 112 | connectionStatusDiv.style.display = "none"; 113 | } 114 | } 115 | 116 | // Handle failed server validation 117 | if (message.type === "SERVER_VALIDATION_FAILED") { 118 | console.log( 119 | `Server validation failed: ${message.reason} - ${message.serverHost}:${message.serverPort}` 120 | ); 121 | 122 | // Update the connection status 123 | serverConnected = false; 124 | updateConnectionBanner(false, null); 125 | 126 | // Start auto-discovery if this was a page refresh validation 127 | if ( 128 | message.reason === "connection_error" || 129 | message.reason === "http_error" 130 | ) { 131 | // If we're not already trying to discover the server, start the process 132 | if (!isDiscoveryInProgress) { 133 | console.log("Starting auto-discovery after validation failure"); 134 | discoverServer(true); 135 | } 136 | } 137 | } 138 | 139 | // Handle successful WebSocket connection 140 | if (message.type === "WEBSOCKET_CONNECTED") { 141 | console.log( 142 | `WebSocket connected to ${message.serverHost}:${message.serverPort}` 143 | ); 144 | 145 | // Update connection status if it wasn't already connected 146 | if (!serverConnected) { 147 | serverConnected = true; 148 | updateConnectionBanner(true, { 149 | name: "Browser Tools Server", 150 | version: "connected via WebSocket", 151 | host: message.serverHost, 152 | port: message.serverPort, 153 | }); 154 | } 155 | } 156 | }); 157 | 158 | // Create connection status banner 159 | function createConnectionBanner() { 160 | // Check if banner already exists 161 | if (document.getElementById("connection-banner")) { 162 | return; 163 | } 164 | 165 | // Create the banner 166 | const banner = document.createElement("div"); 167 | banner.id = "connection-banner"; 168 | banner.style.cssText = ` 169 | padding: 6px 0px; 170 | margin-bottom: 4px; 171 | width: 40%; 172 | display: flex; 173 | flex-direction: column; 174 | align-items: flex-start; 175 | background-color:rgba(0,0,0,0); 176 | border-radius: 11px; 177 | font-size: 11px; 178 | font-weight: 500; 179 | color: #ffffff; 180 | `; 181 | 182 | // Create reconnect button (now placed at the top) 183 | const reconnectButton = document.createElement("button"); 184 | reconnectButton.id = "banner-reconnect-btn"; 185 | reconnectButton.textContent = "Reconnect"; 186 | reconnectButton.style.cssText = ` 187 | background-color: #333333; 188 | color: #ffffff; 189 | border: 1px solid #444444; 190 | border-radius: 3px; 191 | padding: 2px 8px; 192 | font-size: 10px; 193 | cursor: pointer; 194 | margin-bottom: 6px; 195 | align-self: flex-start; 196 | display: none; 197 | transition: background-color 0.2s; 198 | `; 199 | reconnectButton.addEventListener("mouseover", () => { 200 | reconnectButton.style.backgroundColor = "#444444"; 201 | }); 202 | reconnectButton.addEventListener("mouseout", () => { 203 | reconnectButton.style.backgroundColor = "#333333"; 204 | }); 205 | reconnectButton.addEventListener("click", () => { 206 | // Hide the button while reconnecting 207 | reconnectButton.style.display = "none"; 208 | reconnectButton.textContent = "Reconnecting..."; 209 | 210 | // Update UI to show searching state 211 | updateConnectionBanner(false, null); 212 | 213 | // Try to discover server 214 | discoverServer(false); 215 | }); 216 | 217 | // Create a container for the status indicator and text 218 | const statusContainer = document.createElement("div"); 219 | statusContainer.style.cssText = ` 220 | display: flex; 221 | align-items: center; 222 | width: 100%; 223 | `; 224 | 225 | // Create status indicator 226 | const indicator = document.createElement("div"); 227 | indicator.id = "banner-status-indicator"; 228 | indicator.style.cssText = ` 229 | width: 6px; 230 | height: 6px; 231 | position: relative; 232 | top: 1px; 233 | border-radius: 50%; 234 | background-color: #ccc; 235 | margin-right: 8px; 236 | flex-shrink: 0; 237 | transition: background-color 0.3s ease; 238 | `; 239 | 240 | // Create status text 241 | const statusText = document.createElement("div"); 242 | statusText.id = "banner-status-text"; 243 | statusText.textContent = "Searching for server..."; 244 | statusText.style.cssText = 245 | "flex-grow: 1; font-weight: 400; letter-spacing: 0.1px; font-size: 11px;"; 246 | 247 | // Add elements to statusContainer 248 | statusContainer.appendChild(indicator); 249 | statusContainer.appendChild(statusText); 250 | 251 | // Add elements to banner - reconnect button first, then status container 252 | banner.appendChild(reconnectButton); 253 | banner.appendChild(statusContainer); 254 | 255 | // Add banner to the beginning of the document body 256 | // This ensures it's the very first element 257 | document.body.prepend(banner); 258 | 259 | // Set initial state 260 | updateConnectionBanner(false, null); 261 | } 262 | 263 | // Update the connection banner with current status 264 | function updateConnectionBanner(connected, serverInfo) { 265 | const indicator = document.getElementById("banner-status-indicator"); 266 | const statusText = document.getElementById("banner-status-text"); 267 | const banner = document.getElementById("connection-banner"); 268 | const reconnectButton = document.getElementById("banner-reconnect-btn"); 269 | 270 | if (!indicator || !statusText || !banner || !reconnectButton) return; 271 | 272 | if (connected && serverInfo) { 273 | // Connected state with server info 274 | indicator.style.backgroundColor = "#4CAF50"; // Green indicator 275 | statusText.style.color = "#ffffff"; // White text for contrast on black 276 | statusText.textContent = `Connected to ${serverInfo.name} v${serverInfo.version} at ${settings.serverHost}:${settings.serverPort}`; 277 | 278 | // Hide reconnect button when connected 279 | reconnectButton.style.display = "none"; 280 | } else if (connected) { 281 | // Connected without server info 282 | indicator.style.backgroundColor = "#4CAF50"; // Green indicator 283 | statusText.style.color = "#ffffff"; // White text for contrast on black 284 | statusText.textContent = `Connected to server at ${settings.serverHost}:${settings.serverPort}`; 285 | 286 | // Hide reconnect button when connected 287 | reconnectButton.style.display = "none"; 288 | } else { 289 | // Disconnected state 290 | indicator.style.backgroundColor = "#F44336"; // Red indicator 291 | statusText.style.color = "#ffffff"; // White text for contrast on black 292 | 293 | // Only show "searching" message if discovery is in progress 294 | if (isDiscoveryInProgress) { 295 | statusText.textContent = "Not connected to server. Searching..."; 296 | // Hide reconnect button while actively searching 297 | reconnectButton.style.display = "none"; 298 | } else { 299 | statusText.textContent = "Not connected to server."; 300 | // Show reconnect button above status message when disconnected and not searching 301 | reconnectButton.style.display = "block"; 302 | reconnectButton.textContent = "Reconnect"; 303 | } 304 | } 305 | } 306 | 307 | // Initialize UI elements 308 | const logLimitInput = document.getElementById("log-limit"); 309 | const queryLimitInput = document.getElementById("query-limit"); 310 | const stringSizeLimitInput = document.getElementById("string-size-limit"); 311 | const showRequestHeadersCheckbox = document.getElementById( 312 | "show-request-headers" 313 | ); 314 | const showResponseHeadersCheckbox = document.getElementById( 315 | "show-response-headers" 316 | ); 317 | const maxLogSizeInput = document.getElementById("max-log-size"); 318 | const screenshotPathInput = document.getElementById("screenshot-path"); 319 | const captureScreenshotButton = document.getElementById("capture-screenshot"); 320 | 321 | // Server connection UI elements 322 | const serverHostInput = document.getElementById("server-host"); 323 | const serverPortInput = document.getElementById("server-port"); 324 | const discoverServerButton = document.getElementById("discover-server"); 325 | const testConnectionButton = document.getElementById("test-connection"); 326 | const connectionStatusDiv = document.getElementById("connection-status"); 327 | const statusIcon = document.getElementById("status-icon"); 328 | const statusText = document.getElementById("status-text"); 329 | 330 | // Initialize collapsible advanced settings 331 | const advancedSettingsHeader = document.getElementById( 332 | "advanced-settings-header" 333 | ); 334 | const advancedSettingsContent = document.getElementById( 335 | "advanced-settings-content" 336 | ); 337 | const chevronIcon = advancedSettingsHeader.querySelector(".chevron"); 338 | 339 | advancedSettingsHeader.addEventListener("click", () => { 340 | advancedSettingsContent.classList.toggle("visible"); 341 | chevronIcon.classList.toggle("open"); 342 | }); 343 | 344 | // Get all inputs by ID 345 | const allowAutoPasteCheckbox = document.getElementById("allow-auto-paste"); 346 | 347 | // Update UI from settings 348 | function updateUIFromSettings() { 349 | logLimitInput.value = settings.logLimit; 350 | queryLimitInput.value = settings.queryLimit; 351 | stringSizeLimitInput.value = settings.stringSizeLimit; 352 | showRequestHeadersCheckbox.checked = settings.showRequestHeaders; 353 | showResponseHeadersCheckbox.checked = settings.showResponseHeaders; 354 | maxLogSizeInput.value = settings.maxLogSize; 355 | screenshotPathInput.value = settings.screenshotPath; 356 | serverHostInput.value = settings.serverHost; 357 | serverPortInput.value = settings.serverPort; 358 | allowAutoPasteCheckbox.checked = settings.allowAutoPaste; 359 | } 360 | 361 | // Save settings 362 | function saveSettings() { 363 | chrome.storage.local.set({ browserConnectorSettings: settings }); 364 | // Notify devtools.js about settings change 365 | chrome.runtime.sendMessage({ 366 | type: "SETTINGS_UPDATED", 367 | settings, 368 | }); 369 | } 370 | 371 | // Add event listeners for all inputs 372 | logLimitInput.addEventListener("change", (e) => { 373 | settings.logLimit = parseInt(e.target.value, 10); 374 | saveSettings(); 375 | }); 376 | 377 | queryLimitInput.addEventListener("change", (e) => { 378 | settings.queryLimit = parseInt(e.target.value, 10); 379 | saveSettings(); 380 | }); 381 | 382 | stringSizeLimitInput.addEventListener("change", (e) => { 383 | settings.stringSizeLimit = parseInt(e.target.value, 10); 384 | saveSettings(); 385 | }); 386 | 387 | showRequestHeadersCheckbox.addEventListener("change", (e) => { 388 | settings.showRequestHeaders = e.target.checked; 389 | saveSettings(); 390 | }); 391 | 392 | showResponseHeadersCheckbox.addEventListener("change", (e) => { 393 | settings.showResponseHeaders = e.target.checked; 394 | saveSettings(); 395 | }); 396 | 397 | maxLogSizeInput.addEventListener("change", (e) => { 398 | settings.maxLogSize = parseInt(e.target.value, 10); 399 | saveSettings(); 400 | }); 401 | 402 | screenshotPathInput.addEventListener("change", (e) => { 403 | settings.screenshotPath = e.target.value; 404 | saveSettings(); 405 | }); 406 | 407 | // Add event listeners for server settings 408 | serverHostInput.addEventListener("change", (e) => { 409 | settings.serverHost = e.target.value; 410 | saveSettings(); 411 | // Automatically test connection when host is changed 412 | testConnection(settings.serverHost, settings.serverPort); 413 | }); 414 | 415 | serverPortInput.addEventListener("change", (e) => { 416 | settings.serverPort = parseInt(e.target.value, 10); 417 | saveSettings(); 418 | // Automatically test connection when port is changed 419 | testConnection(settings.serverHost, settings.serverPort); 420 | }); 421 | 422 | // Add event listener for auto-paste checkbox 423 | allowAutoPasteCheckbox.addEventListener("change", (e) => { 424 | settings.allowAutoPaste = e.target.checked; 425 | saveSettings(); 426 | }); 427 | 428 | // Function to cancel any ongoing discovery operations 429 | function cancelOngoingDiscovery() { 430 | if (isDiscoveryInProgress) { 431 | console.log("Cancelling ongoing discovery operation"); 432 | 433 | // Abort any fetch requests in progress 434 | if (discoveryController) { 435 | try { 436 | discoveryController.abort(); 437 | } catch (error) { 438 | console.error("Error aborting discovery controller:", error); 439 | } 440 | discoveryController = null; 441 | } 442 | 443 | // Reset the discovery status 444 | isDiscoveryInProgress = false; 445 | 446 | // Update UI to indicate the operation was cancelled 447 | if ( 448 | statusText && 449 | connectionStatusDiv && 450 | connectionStatusDiv.style.display === "block" 451 | ) { 452 | statusText.textContent = "Server discovery operation cancelled"; 453 | } 454 | 455 | // Clear any pending network timeouts that might be part of the discovery process 456 | clearTimeout(reconnectAttemptTimeout); 457 | reconnectAttemptTimeout = null; 458 | 459 | console.log("Discovery operation cancelled successfully"); 460 | } 461 | } 462 | 463 | // Test server connection 464 | testConnectionButton.addEventListener("click", async () => { 465 | // Cancel any ongoing discovery operations before testing 466 | cancelOngoingDiscovery(); 467 | await testConnection(settings.serverHost, settings.serverPort); 468 | }); 469 | 470 | // Function to test server connection 471 | async function testConnection(host, port) { 472 | // Cancel any ongoing discovery operations 473 | cancelOngoingDiscovery(); 474 | 475 | connectionStatusDiv.style.display = "block"; 476 | statusIcon.className = "status-indicator"; 477 | statusText.textContent = "Testing connection..."; 478 | 479 | try { 480 | // Use the identity endpoint instead of .port for more reliable validation 481 | const response = await fetch(`http://${host}:${port}/.identity`, { 482 | signal: AbortSignal.timeout(5000), // 5 second timeout 483 | }); 484 | 485 | if (response.ok) { 486 | const identity = await response.json(); 487 | 488 | // Verify this is actually our server by checking the signature 489 | if (identity.signature !== "mcp-browser-connector-24x7") { 490 | statusIcon.className = "status-indicator status-disconnected"; 491 | statusText.textContent = `Connection failed: Found a server at ${host}:${port} but it's not the Browser Tools server`; 492 | serverConnected = false; 493 | updateConnectionBanner(false, null); 494 | scheduleReconnectAttempt(); 495 | return false; 496 | } 497 | 498 | statusIcon.className = "status-indicator status-connected"; 499 | statusText.textContent = `Connected successfully to ${identity.name} v${identity.version} at ${host}:${port}`; 500 | serverConnected = true; 501 | updateConnectionBanner(true, identity); 502 | 503 | // Clear any scheduled reconnect attempts 504 | if (reconnectAttemptTimeout) { 505 | clearTimeout(reconnectAttemptTimeout); 506 | reconnectAttemptTimeout = null; 507 | } 508 | 509 | // Update settings if different port was discovered 510 | if (parseInt(identity.port, 10) !== port) { 511 | console.log(`Detected different port: ${identity.port}`); 512 | settings.serverPort = parseInt(identity.port, 10); 513 | serverPortInput.value = settings.serverPort; 514 | saveSettings(); 515 | } 516 | 517 | return true; 518 | } else { 519 | statusIcon.className = "status-indicator status-disconnected"; 520 | statusText.textContent = `Connection failed: Server returned ${response.status}`; 521 | serverConnected = false; 522 | 523 | // Make sure isDiscoveryInProgress is false so the reconnect button will show 524 | isDiscoveryInProgress = false; 525 | 526 | // Now update the connection banner to show the reconnect button 527 | updateConnectionBanner(false, null); 528 | scheduleReconnectAttempt(); 529 | return false; 530 | } 531 | } catch (error) { 532 | statusIcon.className = "status-indicator status-disconnected"; 533 | statusText.textContent = `Connection failed: ${error.message}`; 534 | serverConnected = false; 535 | 536 | // Make sure isDiscoveryInProgress is false so the reconnect button will show 537 | isDiscoveryInProgress = false; 538 | 539 | // Now update the connection banner to show the reconnect button 540 | updateConnectionBanner(false, null); 541 | scheduleReconnectAttempt(); 542 | return false; 543 | } 544 | } 545 | 546 | // Schedule a reconnect attempt if server isn't found 547 | function scheduleReconnectAttempt() { 548 | // Clear any existing reconnect timeout 549 | if (reconnectAttemptTimeout) { 550 | clearTimeout(reconnectAttemptTimeout); 551 | } 552 | 553 | // Schedule a reconnect attempt in 30 seconds 554 | reconnectAttemptTimeout = setTimeout(() => { 555 | console.log("Attempting to reconnect to server..."); 556 | // Only show minimal UI during auto-reconnect 557 | discoverServer(true); 558 | }, 30000); // 30 seconds 559 | } 560 | 561 | // Helper function to try connecting to a server 562 | async function tryServerConnection(host, port) { 563 | try { 564 | // Check if the discovery process was cancelled 565 | if (!isDiscoveryInProgress) { 566 | return false; 567 | } 568 | 569 | // Create a local timeout that won't abort the entire discovery process 570 | const controller = new AbortController(); 571 | const timeoutId = setTimeout(() => { 572 | controller.abort(); 573 | }, 500); // 500ms timeout for each connection attempt 574 | 575 | try { 576 | // Use identity endpoint for validation 577 | const response = await fetch(`http://${host}:${port}/.identity`, { 578 | // Use a local controller for this specific request timeout 579 | // but also respect the global discovery cancellation 580 | signal: discoveryController 581 | ? AbortSignal.any([controller.signal, discoveryController.signal]) 582 | : controller.signal, 583 | }); 584 | 585 | clearTimeout(timeoutId); 586 | 587 | // Check again if discovery was cancelled during the fetch 588 | if (!isDiscoveryInProgress) { 589 | return false; 590 | } 591 | 592 | if (response.ok) { 593 | const identity = await response.json(); 594 | 595 | // Verify this is actually our server by checking the signature 596 | if (identity.signature !== "mcp-browser-connector-24x7") { 597 | console.log( 598 | `Found a server at ${host}:${port} but it's not the Browser Tools server` 599 | ); 600 | return false; 601 | } 602 | 603 | console.log(`Successfully found server at ${host}:${port}`); 604 | 605 | // Update settings with discovered server 606 | settings.serverHost = host; 607 | settings.serverPort = parseInt(identity.port, 10); 608 | serverHostInput.value = settings.serverHost; 609 | serverPortInput.value = settings.serverPort; 610 | saveSettings(); 611 | 612 | statusIcon.className = "status-indicator status-connected"; 613 | statusText.textContent = `Discovered ${identity.name} v${identity.version} at ${host}:${identity.port}`; 614 | 615 | // Update connection banner with server info 616 | updateConnectionBanner(true, identity); 617 | 618 | // Update connection status 619 | serverConnected = true; 620 | 621 | // Clear any scheduled reconnect attempts 622 | if (reconnectAttemptTimeout) { 623 | clearTimeout(reconnectAttemptTimeout); 624 | reconnectAttemptTimeout = null; 625 | } 626 | 627 | // End the discovery process 628 | isDiscoveryInProgress = false; 629 | 630 | // Successfully found server 631 | return true; 632 | } 633 | 634 | return false; 635 | } finally { 636 | clearTimeout(timeoutId); 637 | } 638 | } catch (error) { 639 | // Ignore connection errors during discovery 640 | // But check if it was an abort (cancellation) 641 | if (error.name === "AbortError") { 642 | // Check if this was due to the global discovery cancellation 643 | if (discoveryController && discoveryController.signal.aborted) { 644 | console.log("Connection attempt aborted by global cancellation"); 645 | return "aborted"; 646 | } 647 | // Otherwise it was just a timeout for this specific connection attempt 648 | return false; 649 | } 650 | console.log(`Connection error for ${host}:${port}: ${error.message}`); 651 | return false; 652 | } 653 | } 654 | 655 | // Server discovery function (extracted to be reusable) 656 | async function discoverServer(quietMode = false) { 657 | // Cancel any ongoing discovery operations before starting a new one 658 | cancelOngoingDiscovery(); 659 | 660 | // Create a new AbortController for this discovery process 661 | discoveryController = new AbortController(); 662 | isDiscoveryInProgress = true; 663 | 664 | // In quiet mode, we don't show the connection status until we either succeed or fail completely 665 | if (!quietMode) { 666 | connectionStatusDiv.style.display = "block"; 667 | statusIcon.className = "status-indicator"; 668 | statusText.textContent = "Discovering server..."; 669 | } 670 | 671 | // Always update the connection banner 672 | updateConnectionBanner(false, null); 673 | 674 | try { 675 | console.log("Starting server discovery process"); 676 | 677 | // Add an early cancellation listener that will respond to page navigation/refresh 678 | discoveryController.signal.addEventListener("abort", () => { 679 | console.log("Discovery aborted via AbortController signal"); 680 | isDiscoveryInProgress = false; 681 | }); 682 | 683 | // Common IPs to try (in order of likelihood) 684 | const hosts = ["localhost", "127.0.0.1"]; 685 | 686 | // Add the current configured host if it's not already in the list 687 | if ( 688 | !hosts.includes(settings.serverHost) && 689 | settings.serverHost !== "0.0.0.0" 690 | ) { 691 | hosts.unshift(settings.serverHost); // Put at the beginning for priority 692 | } 693 | 694 | // Add common local network IPs 695 | const commonLocalIps = ["192.168.0.", "192.168.1.", "10.0.0.", "10.0.1."]; 696 | for (const prefix of commonLocalIps) { 697 | for (let i = 1; i <= 5; i++) { 698 | // Reduced from 10 to 5 for efficiency 699 | hosts.push(`${prefix}${i}`); 700 | } 701 | } 702 | 703 | // Build port list in a smart order: 704 | // 1. Start with current configured port 705 | // 2. Add default port (3025) 706 | // 3. Add sequential ports around the default (for fallback detection) 707 | const ports = []; 708 | 709 | // Current configured port gets highest priority 710 | const configuredPort = parseInt(settings.serverPort, 10); 711 | ports.push(configuredPort); 712 | 713 | // Add default port if it's not the same as configured 714 | if (configuredPort !== 3025) { 715 | ports.push(3025); 716 | } 717 | 718 | // Add sequential fallback ports (from default up to default+10) 719 | for (let p = 3026; p <= 3035; p++) { 720 | if (p !== configuredPort) { 721 | // Avoid duplicates 722 | ports.push(p); 723 | } 724 | } 725 | 726 | // Remove duplicates 727 | const uniquePorts = [...new Set(ports)]; 728 | console.log("Will check ports:", uniquePorts); 729 | 730 | // Create a progress indicator 731 | let progress = 0; 732 | let totalChecked = 0; 733 | 734 | // Phase 1: Try the most likely combinations first (current host:port and localhost variants) 735 | console.log("Starting Phase 1: Quick check of high-priority hosts/ports"); 736 | const priorityHosts = hosts.slice(0, 2); // First two hosts are highest priority 737 | for (const host of priorityHosts) { 738 | // Check if discovery was cancelled 739 | if (!isDiscoveryInProgress) { 740 | console.log("Discovery process was cancelled during Phase 1"); 741 | return false; 742 | } 743 | 744 | // Try configured port first 745 | totalChecked++; 746 | if (!quietMode) { 747 | statusText.textContent = `Checking ${host}:${uniquePorts[0]}...`; 748 | } 749 | console.log(`Checking ${host}:${uniquePorts[0]}...`); 750 | const result = await tryServerConnection(host, uniquePorts[0]); 751 | 752 | // Check for cancellation or success 753 | if (result === "aborted" || !isDiscoveryInProgress) { 754 | console.log("Discovery process was cancelled"); 755 | return false; 756 | } else if (result === true) { 757 | console.log("Server found in priority check"); 758 | if (quietMode) { 759 | // In quiet mode, only show the connection banner but hide the status box 760 | connectionStatusDiv.style.display = "none"; 761 | } 762 | return true; // Successfully found server 763 | } 764 | 765 | // Then try default port if different 766 | if (uniquePorts.length > 1) { 767 | // Check if discovery was cancelled 768 | if (!isDiscoveryInProgress) { 769 | console.log("Discovery process was cancelled"); 770 | return false; 771 | } 772 | 773 | totalChecked++; 774 | if (!quietMode) { 775 | statusText.textContent = `Checking ${host}:${uniquePorts[1]}...`; 776 | } 777 | console.log(`Checking ${host}:${uniquePorts[1]}...`); 778 | const result = await tryServerConnection(host, uniquePorts[1]); 779 | 780 | // Check for cancellation or success 781 | if (result === "aborted" || !isDiscoveryInProgress) { 782 | console.log("Discovery process was cancelled"); 783 | return false; 784 | } else if (result === true) { 785 | console.log("Server found in priority check"); 786 | if (quietMode) { 787 | // In quiet mode, only show the connection banner but hide the status box 788 | connectionStatusDiv.style.display = "none"; 789 | } 790 | return true; // Successfully found server 791 | } 792 | } 793 | } 794 | 795 | // If we're in quiet mode and the quick checks failed, show the status now 796 | // as we move into more intensive scanning 797 | if (quietMode) { 798 | connectionStatusDiv.style.display = "block"; 799 | statusIcon.className = "status-indicator"; 800 | statusText.textContent = "Searching for server..."; 801 | } 802 | 803 | // Phase 2: Systematic scan of all combinations 804 | const totalAttempts = hosts.length * uniquePorts.length; 805 | console.log( 806 | `Starting Phase 2: Full scan (${totalAttempts} total combinations)` 807 | ); 808 | statusText.textContent = `Quick check failed. Starting full scan (${totalChecked}/${totalAttempts})...`; 809 | 810 | // First, scan through all ports on localhost/127.0.0.1 to find fallback ports quickly 811 | const localHosts = ["localhost", "127.0.0.1"]; 812 | for (const host of localHosts) { 813 | // Skip the first two ports on localhost if we already checked them in Phase 1 814 | const portsToCheck = uniquePorts.slice( 815 | localHosts.includes(host) && priorityHosts.includes(host) ? 2 : 0 816 | ); 817 | 818 | for (const port of portsToCheck) { 819 | // Check if discovery was cancelled 820 | if (!isDiscoveryInProgress) { 821 | console.log("Discovery process was cancelled during local port scan"); 822 | return false; 823 | } 824 | 825 | // Update progress 826 | progress++; 827 | totalChecked++; 828 | statusText.textContent = `Scanning local ports... (${totalChecked}/${totalAttempts}) - Trying ${host}:${port}`; 829 | console.log(`Checking ${host}:${port}...`); 830 | 831 | const result = await tryServerConnection(host, port); 832 | 833 | // Check for cancellation or success 834 | if (result === "aborted" || !isDiscoveryInProgress) { 835 | console.log("Discovery process was cancelled"); 836 | return false; 837 | } else if (result === true) { 838 | console.log(`Server found at ${host}:${port}`); 839 | return true; // Successfully found server 840 | } 841 | } 842 | } 843 | 844 | // Then scan all the remaining host/port combinations 845 | for (const host of hosts) { 846 | // Skip hosts we already checked 847 | if (localHosts.includes(host)) { 848 | continue; 849 | } 850 | 851 | for (const port of uniquePorts) { 852 | // Check if discovery was cancelled 853 | if (!isDiscoveryInProgress) { 854 | console.log("Discovery process was cancelled during remote scan"); 855 | return false; 856 | } 857 | 858 | // Update progress 859 | progress++; 860 | totalChecked++; 861 | statusText.textContent = `Scanning remote hosts... (${totalChecked}/${totalAttempts}) - Trying ${host}:${port}`; 862 | console.log(`Checking ${host}:${port}...`); 863 | 864 | const result = await tryServerConnection(host, port); 865 | 866 | // Check for cancellation or success 867 | if (result === "aborted" || !isDiscoveryInProgress) { 868 | console.log("Discovery process was cancelled"); 869 | return false; 870 | } else if (result === true) { 871 | console.log(`Server found at ${host}:${port}`); 872 | return true; // Successfully found server 873 | } 874 | } 875 | } 876 | 877 | console.log( 878 | `Discovery process completed, checked ${totalChecked} combinations, no server found` 879 | ); 880 | // If we get here, no server was found 881 | statusIcon.className = "status-indicator status-disconnected"; 882 | statusText.textContent = 883 | "No server found. Please check server is running and try again."; 884 | 885 | serverConnected = false; 886 | 887 | // End the discovery process first before updating the banner 888 | isDiscoveryInProgress = false; 889 | 890 | // Update the connection banner to show the reconnect button 891 | updateConnectionBanner(false, null); 892 | 893 | // Schedule a reconnect attempt 894 | scheduleReconnectAttempt(); 895 | 896 | return false; 897 | } catch (error) { 898 | console.error("Error during server discovery:", error); 899 | statusIcon.className = "status-indicator status-disconnected"; 900 | statusText.textContent = `Error discovering server: ${error.message}`; 901 | 902 | serverConnected = false; 903 | 904 | // End the discovery process first before updating the banner 905 | isDiscoveryInProgress = false; 906 | 907 | // Update the connection banner to show the reconnect button 908 | updateConnectionBanner(false, null); 909 | 910 | // Schedule a reconnect attempt 911 | scheduleReconnectAttempt(); 912 | 913 | return false; 914 | } finally { 915 | console.log("Discovery process finished"); 916 | // Always clean up, even if there was an error 917 | if (discoveryController) { 918 | discoveryController = null; 919 | } 920 | } 921 | } 922 | 923 | // Bind discover server button to the extracted function 924 | discoverServerButton.addEventListener("click", () => discoverServer(false)); 925 | 926 | // Screenshot capture functionality 927 | captureScreenshotButton.addEventListener("click", () => { 928 | captureScreenshotButton.textContent = "Capturing..."; 929 | 930 | // Send message to background script to capture screenshot 931 | chrome.runtime.sendMessage( 932 | { 933 | type: "CAPTURE_SCREENSHOT", 934 | tabId: chrome.devtools.inspectedWindow.tabId, 935 | screenshotPath: settings.screenshotPath, 936 | }, 937 | (response) => { 938 | console.log("Screenshot capture response:", response); 939 | if (!response) { 940 | captureScreenshotButton.textContent = "Failed to capture!"; 941 | console.error("Screenshot capture failed: No response received"); 942 | } else if (!response.success) { 943 | captureScreenshotButton.textContent = "Failed to capture!"; 944 | console.error("Screenshot capture failed:", response.error); 945 | } else { 946 | captureScreenshotButton.textContent = `Captured: ${response.title}`; 947 | console.log("Screenshot captured successfully:", response.path); 948 | } 949 | setTimeout(() => { 950 | captureScreenshotButton.textContent = "Capture Screenshot"; 951 | }, 2000); 952 | } 953 | ); 954 | }); 955 | 956 | // Add wipe logs functionality 957 | const wipeLogsButton = document.getElementById("wipe-logs"); 958 | wipeLogsButton.addEventListener("click", () => { 959 | const serverUrl = `http://${settings.serverHost}:${settings.serverPort}/wipelogs`; 960 | console.log(`Sending wipe request to ${serverUrl}`); 961 | 962 | fetch(serverUrl, { 963 | method: "POST", 964 | headers: { "Content-Type": "application/json" }, 965 | }) 966 | .then((response) => response.json()) 967 | .then((result) => { 968 | console.log("Logs wiped successfully:", result.message); 969 | wipeLogsButton.textContent = "Logs Wiped!"; 970 | setTimeout(() => { 971 | wipeLogsButton.textContent = "Wipe All Logs"; 972 | }, 2000); 973 | }) 974 | .catch((error) => { 975 | console.error("Failed to wipe logs:", error); 976 | wipeLogsButton.textContent = "Failed to Wipe Logs"; 977 | setTimeout(() => { 978 | wipeLogsButton.textContent = "Wipe All Logs"; 979 | }, 2000); 980 | }); 981 | }); 982 | ``` -------------------------------------------------------------------------------- /chrome-extension/devtools.js: -------------------------------------------------------------------------------- ```javascript 1 | // devtools.js 2 | 3 | // Store settings with defaults 4 | let settings = { 5 | logLimit: 50, 6 | queryLimit: 30000, 7 | stringSizeLimit: 500, 8 | maxLogSize: 20000, 9 | showRequestHeaders: false, 10 | showResponseHeaders: false, 11 | screenshotPath: "", // Add new setting for screenshot path 12 | serverHost: "localhost", // Default server host 13 | serverPort: 3025, // Default server port 14 | allowAutoPaste: false, // Default auto-paste setting 15 | }; 16 | 17 | // Keep track of debugger state 18 | let isDebuggerAttached = false; 19 | let attachDebuggerRetries = 0; 20 | const currentTabId = chrome.devtools.inspectedWindow.tabId; 21 | const MAX_ATTACH_RETRIES = 3; 22 | const ATTACH_RETRY_DELAY = 1000; // 1 second 23 | 24 | // Load saved settings on startup 25 | chrome.storage.local.get(["browserConnectorSettings"], (result) => { 26 | if (result.browserConnectorSettings) { 27 | settings = { ...settings, ...result.browserConnectorSettings }; 28 | } 29 | }); 30 | 31 | // Listen for settings updates 32 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 33 | if (message.type === "SETTINGS_UPDATED") { 34 | settings = message.settings; 35 | 36 | // If server settings changed and we have a WebSocket, reconnect 37 | if ( 38 | ws && 39 | (message.settings.serverHost !== settings.serverHost || 40 | message.settings.serverPort !== settings.serverPort) 41 | ) { 42 | console.log("Server settings changed, reconnecting WebSocket..."); 43 | setupWebSocket(); 44 | } 45 | } 46 | 47 | // Handle connection status updates from page refreshes 48 | if (message.type === "CONNECTION_STATUS_UPDATE") { 49 | console.log( 50 | `DevTools received connection status update: ${ 51 | message.isConnected ? "Connected" : "Disconnected" 52 | }` 53 | ); 54 | 55 | // If connection is lost, try to reestablish WebSocket only if we had a previous connection 56 | if (!message.isConnected && ws) { 57 | console.log( 58 | "Connection lost after page refresh, will attempt to reconnect WebSocket" 59 | ); 60 | 61 | // Only reconnect if we actually have a WebSocket that might be stale 62 | if ( 63 | ws && 64 | (ws.readyState === WebSocket.CLOSED || 65 | ws.readyState === WebSocket.CLOSING) 66 | ) { 67 | console.log("WebSocket is already closed or closing, will reconnect"); 68 | setupWebSocket(); 69 | } 70 | } 71 | } 72 | 73 | // Handle auto-discovery requests after page refreshes 74 | if (message.type === "INITIATE_AUTO_DISCOVERY") { 75 | console.log( 76 | `DevTools initiating WebSocket reconnect after page refresh (reason: ${message.reason})` 77 | ); 78 | 79 | // For page refreshes with forceRestart, we should always reconnect if our current connection is not working 80 | if ( 81 | (message.reason === "page_refresh" || message.forceRestart === true) && 82 | (!ws || ws.readyState !== WebSocket.OPEN) 83 | ) { 84 | console.log( 85 | "Page refreshed and WebSocket not open - forcing reconnection" 86 | ); 87 | 88 | // Close existing WebSocket if any 89 | if (ws) { 90 | console.log("Closing existing WebSocket due to page refresh"); 91 | intentionalClosure = true; // Mark as intentional to prevent auto-reconnect 92 | try { 93 | ws.close(); 94 | } catch (e) { 95 | console.error("Error closing WebSocket:", e); 96 | } 97 | ws = null; 98 | intentionalClosure = false; // Reset flag 99 | } 100 | 101 | // Clear any pending reconnect timeouts 102 | if (wsReconnectTimeout) { 103 | clearTimeout(wsReconnectTimeout); 104 | wsReconnectTimeout = null; 105 | } 106 | 107 | // Try to reestablish the WebSocket connection 108 | setupWebSocket(); 109 | } 110 | } 111 | }); 112 | 113 | // Utility to recursively truncate strings in any data structure 114 | function truncateStringsInData(data, maxLength, depth = 0, path = "") { 115 | // Add depth limit to prevent circular references 116 | if (depth > 100) { 117 | console.warn("Max depth exceeded at path:", path); 118 | return "[MAX_DEPTH_EXCEEDED]"; 119 | } 120 | 121 | console.log(`Processing at path: ${path}, type:`, typeof data); 122 | 123 | if (typeof data === "string") { 124 | if (data.length > maxLength) { 125 | console.log( 126 | `Truncating string at path ${path} from ${data.length} to ${maxLength}` 127 | ); 128 | return data.substring(0, maxLength) + "... (truncated)"; 129 | } 130 | return data; 131 | } 132 | 133 | if (Array.isArray(data)) { 134 | console.log(`Processing array at path ${path} with length:`, data.length); 135 | return data.map((item, index) => 136 | truncateStringsInData(item, maxLength, depth + 1, `${path}[${index}]`) 137 | ); 138 | } 139 | 140 | if (typeof data === "object" && data !== null) { 141 | console.log( 142 | `Processing object at path ${path} with keys:`, 143 | Object.keys(data) 144 | ); 145 | const result = {}; 146 | for (const [key, value] of Object.entries(data)) { 147 | try { 148 | result[key] = truncateStringsInData( 149 | value, 150 | maxLength, 151 | depth + 1, 152 | path ? `${path}.${key}` : key 153 | ); 154 | } catch (e) { 155 | console.error(`Error processing key ${key} at path ${path}:`, e); 156 | result[key] = "[ERROR_PROCESSING]"; 157 | } 158 | } 159 | return result; 160 | } 161 | 162 | return data; 163 | } 164 | 165 | // Helper to calculate the size of an object 166 | function calculateObjectSize(obj) { 167 | return JSON.stringify(obj).length; 168 | } 169 | 170 | // Helper to process array of objects with size limit 171 | function processArrayWithSizeLimit(array, maxTotalSize, processFunc) { 172 | let currentSize = 0; 173 | const result = []; 174 | 175 | for (const item of array) { 176 | // Process the item first 177 | const processedItem = processFunc(item); 178 | const itemSize = calculateObjectSize(processedItem); 179 | 180 | // Check if adding this item would exceed the limit 181 | if (currentSize + itemSize > maxTotalSize) { 182 | console.log( 183 | `Reached size limit (${currentSize}/${maxTotalSize}), truncating array` 184 | ); 185 | break; 186 | } 187 | 188 | // Add item and update size 189 | result.push(processedItem); 190 | currentSize += itemSize; 191 | console.log( 192 | `Added item of size ${itemSize}, total size now: ${currentSize}` 193 | ); 194 | } 195 | 196 | return result; 197 | } 198 | 199 | // Modified processJsonString to handle arrays with size limit 200 | function processJsonString(jsonString, maxLength) { 201 | console.log("Processing string of length:", jsonString?.length); 202 | try { 203 | let parsed; 204 | try { 205 | parsed = JSON.parse(jsonString); 206 | console.log( 207 | "Successfully parsed as JSON, structure:", 208 | JSON.stringify(Object.keys(parsed)) 209 | ); 210 | } catch (e) { 211 | console.log("Not valid JSON, treating as string"); 212 | return truncateStringsInData(jsonString, maxLength, 0, "root"); 213 | } 214 | 215 | // If it's an array, process with size limit 216 | if (Array.isArray(parsed)) { 217 | console.log("Processing array of objects with size limit"); 218 | const processed = processArrayWithSizeLimit( 219 | parsed, 220 | settings.maxLogSize, 221 | (item) => truncateStringsInData(item, maxLength, 0, "root") 222 | ); 223 | const result = JSON.stringify(processed); 224 | console.log( 225 | `Processed array: ${parsed.length} -> ${processed.length} items` 226 | ); 227 | return result; 228 | } 229 | 230 | // Otherwise process as before 231 | const processed = truncateStringsInData(parsed, maxLength, 0, "root"); 232 | const result = JSON.stringify(processed); 233 | console.log("Processed JSON string length:", result.length); 234 | return result; 235 | } catch (e) { 236 | console.error("Error in processJsonString:", e); 237 | return jsonString.substring(0, maxLength) + "... (truncated)"; 238 | } 239 | } 240 | 241 | // Helper to send logs to browser-connector 242 | async function sendToBrowserConnector(logData) { 243 | if (!logData) { 244 | console.error("No log data provided to sendToBrowserConnector"); 245 | return; 246 | } 247 | 248 | // First, ensure we're connecting to the right server 249 | if (!(await validateServerIdentity())) { 250 | console.error( 251 | "Cannot send logs: Not connected to a valid browser tools server" 252 | ); 253 | return; 254 | } 255 | 256 | console.log("Sending log data to browser connector:", { 257 | type: logData.type, 258 | timestamp: logData.timestamp, 259 | }); 260 | 261 | // Process any string fields that might contain JSON 262 | const processedData = { ...logData }; 263 | 264 | if (logData.type === "network-request") { 265 | console.log("Processing network request"); 266 | if (processedData.requestBody) { 267 | console.log( 268 | "Request body size before:", 269 | processedData.requestBody.length 270 | ); 271 | processedData.requestBody = processJsonString( 272 | processedData.requestBody, 273 | settings.stringSizeLimit 274 | ); 275 | console.log("Request body size after:", processedData.requestBody.length); 276 | } 277 | if (processedData.responseBody) { 278 | console.log( 279 | "Response body size before:", 280 | processedData.responseBody.length 281 | ); 282 | processedData.responseBody = processJsonString( 283 | processedData.responseBody, 284 | settings.stringSizeLimit 285 | ); 286 | console.log( 287 | "Response body size after:", 288 | processedData.responseBody.length 289 | ); 290 | } 291 | } else if ( 292 | logData.type === "console-log" || 293 | logData.type === "console-error" 294 | ) { 295 | console.log("Processing console message"); 296 | if (processedData.message) { 297 | console.log("Message size before:", processedData.message.length); 298 | processedData.message = processJsonString( 299 | processedData.message, 300 | settings.stringSizeLimit 301 | ); 302 | console.log("Message size after:", processedData.message.length); 303 | } 304 | } 305 | 306 | // Add settings to the request 307 | const payload = { 308 | data: { 309 | ...processedData, 310 | timestamp: Date.now(), 311 | }, 312 | settings: { 313 | logLimit: settings.logLimit, 314 | queryLimit: settings.queryLimit, 315 | showRequestHeaders: settings.showRequestHeaders, 316 | showResponseHeaders: settings.showResponseHeaders, 317 | }, 318 | }; 319 | 320 | const finalPayloadSize = JSON.stringify(payload).length; 321 | console.log("Final payload size:", finalPayloadSize); 322 | 323 | if (finalPayloadSize > 1000000) { 324 | console.warn("Warning: Large payload detected:", finalPayloadSize); 325 | console.warn( 326 | "Payload preview:", 327 | JSON.stringify(payload).substring(0, 1000) + "..." 328 | ); 329 | } 330 | 331 | const serverUrl = `http://${settings.serverHost}:${settings.serverPort}/extension-log`; 332 | console.log(`Sending log to ${serverUrl}`); 333 | 334 | fetch(serverUrl, { 335 | method: "POST", 336 | headers: { "Content-Type": "application/json" }, 337 | body: JSON.stringify(payload), 338 | }) 339 | .then((response) => { 340 | if (!response.ok) { 341 | throw new Error(`HTTP error ${response.status}`); 342 | } 343 | return response.json(); 344 | }) 345 | .then((data) => { 346 | console.log("Log sent successfully:", data); 347 | }) 348 | .catch((error) => { 349 | console.error("Error sending log:", error); 350 | }); 351 | } 352 | 353 | // Validate server identity 354 | async function validateServerIdentity() { 355 | try { 356 | console.log( 357 | `Validating server identity at ${settings.serverHost}:${settings.serverPort}...` 358 | ); 359 | 360 | // Use fetch with a timeout to prevent long-hanging requests 361 | const response = await fetch( 362 | `http://${settings.serverHost}:${settings.serverPort}/.identity`, 363 | { 364 | signal: AbortSignal.timeout(3000), // 3 second timeout 365 | } 366 | ); 367 | 368 | if (!response.ok) { 369 | console.error( 370 | `Server identity validation failed: HTTP ${response.status}` 371 | ); 372 | 373 | // Notify about the connection failure 374 | chrome.runtime.sendMessage({ 375 | type: "SERVER_VALIDATION_FAILED", 376 | reason: "http_error", 377 | status: response.status, 378 | serverHost: settings.serverHost, 379 | serverPort: settings.serverPort, 380 | }); 381 | 382 | return false; 383 | } 384 | 385 | const identity = await response.json(); 386 | 387 | // Validate signature 388 | if (identity.signature !== "mcp-browser-connector-24x7") { 389 | console.error("Server identity validation failed: Invalid signature"); 390 | 391 | // Notify about the invalid signature 392 | chrome.runtime.sendMessage({ 393 | type: "SERVER_VALIDATION_FAILED", 394 | reason: "invalid_signature", 395 | serverHost: settings.serverHost, 396 | serverPort: settings.serverPort, 397 | }); 398 | 399 | return false; 400 | } 401 | 402 | console.log( 403 | `Server identity confirmed: ${identity.name} v${identity.version}` 404 | ); 405 | 406 | // Notify about successful validation 407 | chrome.runtime.sendMessage({ 408 | type: "SERVER_VALIDATION_SUCCESS", 409 | serverInfo: identity, 410 | serverHost: settings.serverHost, 411 | serverPort: settings.serverPort, 412 | }); 413 | 414 | return true; 415 | } catch (error) { 416 | console.error("Server identity validation failed:", error); 417 | 418 | // Notify about the connection error 419 | chrome.runtime.sendMessage({ 420 | type: "SERVER_VALIDATION_FAILED", 421 | reason: "connection_error", 422 | error: error.message, 423 | serverHost: settings.serverHost, 424 | serverPort: settings.serverPort, 425 | }); 426 | 427 | return false; 428 | } 429 | } 430 | 431 | // Function to clear logs on the server 432 | function wipeLogs() { 433 | console.log("Wiping all logs..."); 434 | 435 | const serverUrl = `http://${settings.serverHost}:${settings.serverPort}/wipelogs`; 436 | console.log(`Sending wipe request to ${serverUrl}`); 437 | 438 | fetch(serverUrl, { 439 | method: "POST", 440 | headers: { "Content-Type": "application/json" }, 441 | }) 442 | .then((response) => { 443 | if (!response.ok) { 444 | throw new Error(`HTTP error ${response.status}`); 445 | } 446 | return response.json(); 447 | }) 448 | .then((data) => { 449 | console.log("Logs wiped successfully:", data); 450 | }) 451 | .catch((error) => { 452 | console.error("Error wiping logs:", error); 453 | }); 454 | } 455 | 456 | // Listen for page refreshes 457 | chrome.devtools.network.onNavigated.addListener((url) => { 458 | console.log("Page navigated/refreshed - wiping logs"); 459 | wipeLogs(); 460 | 461 | // Send the new URL to the server 462 | if (ws && ws.readyState === WebSocket.OPEN && url) { 463 | console.log( 464 | "Chrome Extension: Sending page-navigated event with URL:", 465 | url 466 | ); 467 | ws.send( 468 | JSON.stringify({ 469 | type: "page-navigated", 470 | url: url, 471 | tabId: chrome.devtools.inspectedWindow.tabId, 472 | timestamp: Date.now(), 473 | }) 474 | ); 475 | } 476 | }); 477 | 478 | // 1) Listen for network requests 479 | chrome.devtools.network.onRequestFinished.addListener((request) => { 480 | if (request._resourceType === "xhr" || request._resourceType === "fetch") { 481 | request.getContent((responseBody) => { 482 | const entry = { 483 | type: "network-request", 484 | url: request.request.url, 485 | method: request.request.method, 486 | status: request.response.status, 487 | requestHeaders: request.request.headers, 488 | responseHeaders: request.response.headers, 489 | requestBody: request.request.postData?.text ?? "", 490 | responseBody: responseBody ?? "", 491 | }; 492 | sendToBrowserConnector(entry); 493 | }); 494 | } 495 | }); 496 | 497 | // Helper function to attach debugger 498 | async function attachDebugger() { 499 | // First check if we're already attached to this tab 500 | chrome.debugger.getTargets((targets) => { 501 | const isAlreadyAttached = targets.some( 502 | (target) => target.tabId === currentTabId && target.attached 503 | ); 504 | 505 | if (isAlreadyAttached) { 506 | console.log("Found existing debugger attachment, detaching first..."); 507 | // Force detach first to ensure clean state 508 | chrome.debugger.detach({ tabId: currentTabId }, () => { 509 | // Ignore any errors during detach 510 | if (chrome.runtime.lastError) { 511 | console.log("Error during forced detach:", chrome.runtime.lastError); 512 | } 513 | // Now proceed with fresh attachment 514 | performAttach(); 515 | }); 516 | } else { 517 | // No existing attachment, proceed directly 518 | performAttach(); 519 | } 520 | }); 521 | } 522 | 523 | function performAttach() { 524 | console.log("Performing debugger attachment to tab:", currentTabId); 525 | chrome.debugger.attach({ tabId: currentTabId }, "1.3", () => { 526 | if (chrome.runtime.lastError) { 527 | console.error("Failed to attach debugger:", chrome.runtime.lastError); 528 | isDebuggerAttached = false; 529 | return; 530 | } 531 | 532 | isDebuggerAttached = true; 533 | console.log("Debugger successfully attached"); 534 | 535 | // Add the event listener when attaching 536 | chrome.debugger.onEvent.addListener(consoleMessageListener); 537 | 538 | chrome.debugger.sendCommand( 539 | { tabId: currentTabId }, 540 | "Runtime.enable", 541 | {}, 542 | () => { 543 | if (chrome.runtime.lastError) { 544 | console.error("Failed to enable runtime:", chrome.runtime.lastError); 545 | return; 546 | } 547 | console.log("Runtime API successfully enabled"); 548 | } 549 | ); 550 | }); 551 | } 552 | 553 | // Helper function to detach debugger 554 | function detachDebugger() { 555 | // Remove the event listener first 556 | chrome.debugger.onEvent.removeListener(consoleMessageListener); 557 | 558 | // Check if debugger is actually attached before trying to detach 559 | chrome.debugger.getTargets((targets) => { 560 | const isStillAttached = targets.some( 561 | (target) => target.tabId === currentTabId && target.attached 562 | ); 563 | 564 | if (!isStillAttached) { 565 | console.log("Debugger already detached"); 566 | isDebuggerAttached = false; 567 | return; 568 | } 569 | 570 | chrome.debugger.detach({ tabId: currentTabId }, () => { 571 | if (chrome.runtime.lastError) { 572 | console.warn( 573 | "Warning during debugger detach:", 574 | chrome.runtime.lastError 575 | ); 576 | } 577 | isDebuggerAttached = false; 578 | console.log("Debugger detached"); 579 | }); 580 | }); 581 | } 582 | 583 | // Move the console message listener outside the panel creation 584 | const consoleMessageListener = (source, method, params) => { 585 | // Only process events for our tab 586 | if (source.tabId !== currentTabId) { 587 | return; 588 | } 589 | 590 | if (method === "Runtime.exceptionThrown") { 591 | const entry = { 592 | type: "console-error", 593 | message: 594 | params.exceptionDetails.exception?.description || 595 | JSON.stringify(params.exceptionDetails), 596 | level: "error", 597 | timestamp: Date.now(), 598 | }; 599 | console.log("Sending runtime exception:", entry); 600 | sendToBrowserConnector(entry); 601 | } 602 | 603 | if (method === "Runtime.consoleAPICalled") { 604 | // Process all arguments from the console call 605 | let formattedMessage = ""; 606 | const args = params.args || []; 607 | 608 | // Extract all arguments and combine them 609 | if (args.length > 0) { 610 | // Try to build a meaningful representation of all arguments 611 | try { 612 | formattedMessage = args 613 | .map((arg) => { 614 | // Handle different types of arguments 615 | if (arg.type === "string") { 616 | return arg.value; 617 | } else if (arg.type === "object" && arg.preview) { 618 | // For objects, include their preview or description 619 | return JSON.stringify(arg.preview); 620 | } else if (arg.description) { 621 | // Some objects have descriptions 622 | return arg.description; 623 | } else { 624 | // Fallback for other types 625 | return arg.value || arg.description || JSON.stringify(arg); 626 | } 627 | }) 628 | .join(" "); 629 | } catch (e) { 630 | // Fallback if processing fails 631 | console.error("Failed to process console arguments:", e); 632 | formattedMessage = 633 | args[0]?.value || "Unable to process console arguments"; 634 | } 635 | } 636 | 637 | const entry = { 638 | type: params.type === "error" ? "console-error" : "console-log", 639 | level: params.type, 640 | message: formattedMessage, 641 | timestamp: Date.now(), 642 | }; 643 | console.log("Sending console entry:", entry); 644 | sendToBrowserConnector(entry); 645 | } 646 | }; 647 | 648 | // 2) Use DevTools Protocol to capture console logs 649 | chrome.devtools.panels.create("BrowserToolsMCP", "", "panel.html", (panel) => { 650 | // Initial attach - we'll keep the debugger attached as long as DevTools is open 651 | attachDebugger(); 652 | 653 | // Handle panel showing 654 | panel.onShown.addListener((panelWindow) => { 655 | if (!isDebuggerAttached) { 656 | attachDebugger(); 657 | } 658 | }); 659 | }); 660 | 661 | // Clean up when DevTools closes 662 | window.addEventListener("unload", () => { 663 | // Detach debugger 664 | detachDebugger(); 665 | 666 | // Set intentional closure flag before closing 667 | intentionalClosure = true; 668 | 669 | if (ws) { 670 | try { 671 | ws.close(); 672 | } catch (e) { 673 | console.error("Error closing WebSocket during unload:", e); 674 | } 675 | ws = null; 676 | } 677 | 678 | if (wsReconnectTimeout) { 679 | clearTimeout(wsReconnectTimeout); 680 | wsReconnectTimeout = null; 681 | } 682 | 683 | if (heartbeatInterval) { 684 | clearInterval(heartbeatInterval); 685 | heartbeatInterval = null; 686 | } 687 | }); 688 | 689 | // Function to capture and send element data 690 | function captureAndSendElement() { 691 | chrome.devtools.inspectedWindow.eval( 692 | `(function() { 693 | const el = $0; // $0 is the currently selected element in DevTools 694 | if (!el) return null; 695 | 696 | const rect = el.getBoundingClientRect(); 697 | 698 | return { 699 | tagName: el.tagName, 700 | id: el.id, 701 | className: el.className, 702 | textContent: el.textContent?.substring(0, 100), 703 | attributes: Array.from(el.attributes).map(attr => ({ 704 | name: attr.name, 705 | value: attr.value 706 | })), 707 | dimensions: { 708 | width: rect.width, 709 | height: rect.height, 710 | top: rect.top, 711 | left: rect.left 712 | }, 713 | innerHTML: el.innerHTML.substring(0, 500) 714 | }; 715 | })()`, 716 | (result, isException) => { 717 | if (isException || !result) return; 718 | 719 | console.log("Element selected:", result); 720 | 721 | // Send to browser connector 722 | sendToBrowserConnector({ 723 | type: "selected-element", 724 | timestamp: Date.now(), 725 | element: result, 726 | }); 727 | } 728 | ); 729 | } 730 | 731 | // Listen for element selection in the Elements panel 732 | chrome.devtools.panels.elements.onSelectionChanged.addListener(() => { 733 | captureAndSendElement(); 734 | }); 735 | 736 | // WebSocket connection management 737 | let ws = null; 738 | let wsReconnectTimeout = null; 739 | let heartbeatInterval = null; 740 | const WS_RECONNECT_DELAY = 5000; // 5 seconds 741 | const HEARTBEAT_INTERVAL = 30000; // 30 seconds 742 | // Add a flag to track if we need to reconnect after identity validation 743 | let reconnectAfterValidation = false; 744 | // Track if we're intentionally closing the connection 745 | let intentionalClosure = false; 746 | 747 | // Function to send a heartbeat to keep the WebSocket connection alive 748 | function sendHeartbeat() { 749 | if (ws && ws.readyState === WebSocket.OPEN) { 750 | console.log("Chrome Extension: Sending WebSocket heartbeat"); 751 | ws.send(JSON.stringify({ type: "heartbeat" })); 752 | } 753 | } 754 | 755 | async function setupWebSocket() { 756 | // Clear any pending timeouts 757 | if (wsReconnectTimeout) { 758 | clearTimeout(wsReconnectTimeout); 759 | wsReconnectTimeout = null; 760 | } 761 | 762 | if (heartbeatInterval) { 763 | clearInterval(heartbeatInterval); 764 | heartbeatInterval = null; 765 | } 766 | 767 | // Close existing WebSocket if any 768 | if (ws) { 769 | // Set flag to indicate this is an intentional closure 770 | intentionalClosure = true; 771 | try { 772 | ws.close(); 773 | } catch (e) { 774 | console.error("Error closing existing WebSocket:", e); 775 | } 776 | ws = null; 777 | intentionalClosure = false; // Reset flag 778 | } 779 | 780 | // Validate server identity before connecting 781 | console.log("Validating server identity before WebSocket connection..."); 782 | const isValid = await validateServerIdentity(); 783 | 784 | if (!isValid) { 785 | console.error( 786 | "Cannot establish WebSocket: Not connected to a valid browser tools server" 787 | ); 788 | // Set flag to indicate we need to reconnect after a page refresh check 789 | reconnectAfterValidation = true; 790 | 791 | // Try again after delay 792 | wsReconnectTimeout = setTimeout(() => { 793 | console.log("Attempting to reconnect WebSocket after validation failure"); 794 | setupWebSocket(); 795 | }, WS_RECONNECT_DELAY); 796 | return; 797 | } 798 | 799 | // Reset reconnect flag since validation succeeded 800 | reconnectAfterValidation = false; 801 | 802 | const wsUrl = `ws://${settings.serverHost}:${settings.serverPort}/extension-ws`; 803 | console.log(`Connecting to WebSocket at ${wsUrl}`); 804 | 805 | try { 806 | ws = new WebSocket(wsUrl); 807 | 808 | ws.onopen = () => { 809 | console.log(`Chrome Extension: WebSocket connected to ${wsUrl}`); 810 | 811 | // Start heartbeat to keep connection alive 812 | heartbeatInterval = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL); 813 | 814 | // Notify that connection is successful 815 | chrome.runtime.sendMessage({ 816 | type: "WEBSOCKET_CONNECTED", 817 | serverHost: settings.serverHost, 818 | serverPort: settings.serverPort, 819 | }); 820 | 821 | // Send the current URL to the server right after connection 822 | // This ensures the server has the URL even if no navigation occurs 823 | chrome.runtime.sendMessage( 824 | { 825 | type: "GET_CURRENT_URL", 826 | tabId: chrome.devtools.inspectedWindow.tabId, 827 | }, 828 | (response) => { 829 | if (chrome.runtime.lastError) { 830 | console.error( 831 | "Chrome Extension: Error getting URL from background on connection:", 832 | chrome.runtime.lastError 833 | ); 834 | 835 | // If normal method fails, try fallback to chrome.tabs API directly 836 | tryFallbackGetUrl(); 837 | return; 838 | } 839 | 840 | if (response && response.url) { 841 | console.log( 842 | "Chrome Extension: Sending initial URL to server:", 843 | response.url 844 | ); 845 | 846 | // Send the URL to the server via the background script 847 | chrome.runtime.sendMessage({ 848 | type: "UPDATE_SERVER_URL", 849 | tabId: chrome.devtools.inspectedWindow.tabId, 850 | url: response.url, 851 | source: "initial_connection", 852 | }); 853 | } else { 854 | // If response exists but no URL, try fallback 855 | tryFallbackGetUrl(); 856 | } 857 | } 858 | ); 859 | 860 | // Fallback method to get URL directly 861 | function tryFallbackGetUrl() { 862 | console.log("Chrome Extension: Trying fallback method to get URL"); 863 | 864 | // Try to get the URL directly using the tabs API 865 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 866 | if (chrome.runtime.lastError) { 867 | console.error( 868 | "Chrome Extension: Fallback URL retrieval failed:", 869 | chrome.runtime.lastError 870 | ); 871 | return; 872 | } 873 | 874 | if (tabs && tabs.length > 0 && tabs[0].url) { 875 | console.log( 876 | "Chrome Extension: Got URL via fallback method:", 877 | tabs[0].url 878 | ); 879 | 880 | // Send the URL to the server 881 | chrome.runtime.sendMessage({ 882 | type: "UPDATE_SERVER_URL", 883 | tabId: chrome.devtools.inspectedWindow.tabId, 884 | url: tabs[0].url, 885 | source: "fallback_method", 886 | }); 887 | } else { 888 | console.warn( 889 | "Chrome Extension: Could not retrieve URL through fallback method" 890 | ); 891 | } 892 | }); 893 | } 894 | }; 895 | 896 | ws.onerror = (error) => { 897 | console.error(`Chrome Extension: WebSocket error for ${wsUrl}:`, error); 898 | }; 899 | 900 | ws.onclose = (event) => { 901 | console.log(`Chrome Extension: WebSocket closed for ${wsUrl}:`, event); 902 | 903 | // Stop heartbeat 904 | if (heartbeatInterval) { 905 | clearInterval(heartbeatInterval); 906 | heartbeatInterval = null; 907 | } 908 | 909 | // Don't reconnect if this was an intentional closure 910 | if (intentionalClosure) { 911 | console.log( 912 | "Chrome Extension: Intentional WebSocket closure, not reconnecting" 913 | ); 914 | return; 915 | } 916 | 917 | // Only attempt to reconnect if the closure wasn't intentional 918 | // Code 1000 (Normal Closure) and 1001 (Going Away) are normal closures 919 | // Code 1005 often happens with clean closures in Chrome 920 | const isAbnormalClosure = !(event.code === 1000 || event.code === 1001); 921 | 922 | // Check if this was an abnormal closure or if we need to reconnect after validation 923 | if (isAbnormalClosure || reconnectAfterValidation) { 924 | console.log( 925 | `Chrome Extension: Will attempt to reconnect WebSocket (closure code: ${event.code})` 926 | ); 927 | 928 | // Try to reconnect after delay 929 | wsReconnectTimeout = setTimeout(() => { 930 | console.log( 931 | `Chrome Extension: Attempting to reconnect WebSocket to ${wsUrl}` 932 | ); 933 | setupWebSocket(); 934 | }, WS_RECONNECT_DELAY); 935 | } else { 936 | console.log( 937 | `Chrome Extension: Normal WebSocket closure, not reconnecting automatically` 938 | ); 939 | } 940 | }; 941 | 942 | ws.onmessage = async (event) => { 943 | try { 944 | const message = JSON.parse(event.data); 945 | 946 | // Don't log heartbeat responses to reduce noise 947 | if (message.type !== "heartbeat-response") { 948 | console.log("Chrome Extension: Received WebSocket message:", message); 949 | 950 | if (message.type === "server-shutdown") { 951 | console.log("Chrome Extension: Received server shutdown signal"); 952 | // Clear any reconnection attempts 953 | if (wsReconnectTimeout) { 954 | clearTimeout(wsReconnectTimeout); 955 | wsReconnectTimeout = null; 956 | } 957 | // Close the connection gracefully 958 | ws.close(1000, "Server shutting down"); 959 | return; 960 | } 961 | } 962 | 963 | if (message.type === "heartbeat-response") { 964 | // Just a heartbeat response, no action needed 965 | // Uncomment the next line for debug purposes only 966 | // console.log("Chrome Extension: Received heartbeat response"); 967 | } else if (message.type === "take-screenshot") { 968 | console.log("Chrome Extension: Taking screenshot..."); 969 | // Capture screenshot of the current tab 970 | chrome.tabs.captureVisibleTab(null, { format: "png" }, (dataUrl) => { 971 | if (chrome.runtime.lastError) { 972 | console.error( 973 | "Chrome Extension: Screenshot capture failed:", 974 | chrome.runtime.lastError 975 | ); 976 | ws.send( 977 | JSON.stringify({ 978 | type: "screenshot-error", 979 | error: chrome.runtime.lastError.message, 980 | requestId: message.requestId, 981 | }) 982 | ); 983 | return; 984 | } 985 | 986 | console.log("Chrome Extension: Screenshot captured successfully"); 987 | // Just send the screenshot data, let the server handle paths 988 | const response = { 989 | type: "screenshot-data", 990 | data: dataUrl, 991 | requestId: message.requestId, 992 | // Only include path if it's configured in settings 993 | ...(settings.screenshotPath && { path: settings.screenshotPath }), 994 | // Include auto-paste setting 995 | autoPaste: settings.allowAutoPaste, 996 | }; 997 | 998 | console.log("Chrome Extension: Sending screenshot data response", { 999 | ...response, 1000 | data: "[base64 data]", 1001 | }); 1002 | 1003 | ws.send(JSON.stringify(response)); 1004 | }); 1005 | } else if (message.type === "get-current-url") { 1006 | console.log("Chrome Extension: Received request for current URL"); 1007 | 1008 | // Get the current URL from the background script instead of inspectedWindow.eval 1009 | let retryCount = 0; 1010 | const maxRetries = 2; 1011 | 1012 | const requestCurrentUrl = () => { 1013 | chrome.runtime.sendMessage( 1014 | { 1015 | type: "GET_CURRENT_URL", 1016 | tabId: chrome.devtools.inspectedWindow.tabId, 1017 | }, 1018 | (response) => { 1019 | if (chrome.runtime.lastError) { 1020 | console.error( 1021 | "Chrome Extension: Error getting URL from background:", 1022 | chrome.runtime.lastError 1023 | ); 1024 | 1025 | // Retry logic 1026 | if (retryCount < maxRetries) { 1027 | retryCount++; 1028 | console.log( 1029 | `Retrying URL request (${retryCount}/${maxRetries})...` 1030 | ); 1031 | setTimeout(requestCurrentUrl, 500); // Wait 500ms before retrying 1032 | return; 1033 | } 1034 | 1035 | ws.send( 1036 | JSON.stringify({ 1037 | type: "current-url-response", 1038 | url: null, 1039 | tabId: chrome.devtools.inspectedWindow.tabId, 1040 | error: 1041 | "Failed to get URL from background: " + 1042 | chrome.runtime.lastError.message, 1043 | requestId: message.requestId, 1044 | }) 1045 | ); 1046 | return; 1047 | } 1048 | 1049 | if (response && response.success && response.url) { 1050 | console.log( 1051 | "Chrome Extension: Got URL from background:", 1052 | response.url 1053 | ); 1054 | ws.send( 1055 | JSON.stringify({ 1056 | type: "current-url-response", 1057 | url: response.url, 1058 | tabId: chrome.devtools.inspectedWindow.tabId, 1059 | requestId: message.requestId, 1060 | }) 1061 | ); 1062 | } else { 1063 | console.error( 1064 | "Chrome Extension: Invalid URL response from background:", 1065 | response 1066 | ); 1067 | 1068 | // Last resort - try to get URL directly from the tab 1069 | chrome.tabs.query( 1070 | { active: true, currentWindow: true }, 1071 | (tabs) => { 1072 | const url = tabs && tabs[0] && tabs[0].url; 1073 | console.log( 1074 | "Chrome Extension: Got URL directly from tab:", 1075 | url 1076 | ); 1077 | 1078 | ws.send( 1079 | JSON.stringify({ 1080 | type: "current-url-response", 1081 | url: url || null, 1082 | tabId: chrome.devtools.inspectedWindow.tabId, 1083 | error: 1084 | response?.error || 1085 | "Failed to get URL from background", 1086 | requestId: message.requestId, 1087 | }) 1088 | ); 1089 | } 1090 | ); 1091 | } 1092 | } 1093 | ); 1094 | }; 1095 | 1096 | requestCurrentUrl(); 1097 | } 1098 | } catch (error) { 1099 | console.error( 1100 | "Chrome Extension: Error processing WebSocket message:", 1101 | error 1102 | ); 1103 | } 1104 | }; 1105 | } catch (error) { 1106 | console.error("Error creating WebSocket:", error); 1107 | // Try again after delay 1108 | wsReconnectTimeout = setTimeout(setupWebSocket, WS_RECONNECT_DELAY); 1109 | } 1110 | } 1111 | 1112 | // Initialize WebSocket connection when DevTools opens 1113 | setupWebSocket(); 1114 | 1115 | // Clean up WebSocket when DevTools closes 1116 | window.addEventListener("unload", () => { 1117 | if (ws) { 1118 | ws.close(); 1119 | } 1120 | if (wsReconnectTimeout) { 1121 | clearTimeout(wsReconnectTimeout); 1122 | } 1123 | }); 1124 | ``` -------------------------------------------------------------------------------- /browser-tools-mcp/mcp-server.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 5 | import path from "path"; 6 | import fs from "fs"; 7 | 8 | // Create the MCP server 9 | const server = new McpServer({ 10 | name: "Browser Tools MCP", 11 | version: "1.2.0", 12 | }); 13 | 14 | // Track the discovered server connection 15 | let discoveredHost = "127.0.0.1"; 16 | let discoveredPort = 3025; 17 | let serverDiscovered = false; 18 | 19 | // Function to get the default port from environment variable or default 20 | function getDefaultServerPort(): number { 21 | // Check environment variable first 22 | if (process.env.BROWSER_TOOLS_PORT) { 23 | const envPort = parseInt(process.env.BROWSER_TOOLS_PORT, 10); 24 | if (!isNaN(envPort) && envPort > 0) { 25 | return envPort; 26 | } 27 | } 28 | 29 | // Try to read from .port file 30 | try { 31 | const portFilePath = path.join(__dirname, ".port"); 32 | if (fs.existsSync(portFilePath)) { 33 | const port = parseInt(fs.readFileSync(portFilePath, "utf8").trim(), 10); 34 | if (!isNaN(port) && port > 0) { 35 | return port; 36 | } 37 | } 38 | } catch (err) { 39 | console.error("Error reading port file:", err); 40 | } 41 | 42 | // Default port if no configuration found 43 | return 3025; 44 | } 45 | 46 | // Function to get default server host from environment variable or default 47 | function getDefaultServerHost(): string { 48 | // Check environment variable first 49 | if (process.env.BROWSER_TOOLS_HOST) { 50 | return process.env.BROWSER_TOOLS_HOST; 51 | } 52 | 53 | // Default to localhost 54 | return "127.0.0.1"; 55 | } 56 | 57 | // Server discovery function - similar to what you have in the Chrome extension 58 | async function discoverServer(): Promise<boolean> { 59 | console.log("Starting server discovery process"); 60 | 61 | // Common hosts to try 62 | const hosts = [getDefaultServerHost(), "127.0.0.1", "localhost"]; 63 | 64 | // Ports to try (start with default, then try others) 65 | const defaultPort = getDefaultServerPort(); 66 | const ports = [defaultPort]; 67 | 68 | // Add additional ports (fallback range) 69 | for (let p = 3025; p <= 3035; p++) { 70 | if (p !== defaultPort) { 71 | ports.push(p); 72 | } 73 | } 74 | 75 | console.log(`Will try hosts: ${hosts.join(", ")}`); 76 | console.log(`Will try ports: ${ports.join(", ")}`); 77 | 78 | // Try to find the server 79 | for (const host of hosts) { 80 | for (const port of ports) { 81 | try { 82 | console.log(`Checking ${host}:${port}...`); 83 | 84 | // Use the identity endpoint for validation 85 | const response = await fetch(`http://${host}:${port}/.identity`, { 86 | signal: AbortSignal.timeout(1000), // 1 second timeout 87 | }); 88 | 89 | if (response.ok) { 90 | const identity = await response.json(); 91 | 92 | // Verify this is actually our server by checking the signature 93 | if (identity.signature === "mcp-browser-connector-24x7") { 94 | console.log(`Successfully found server at ${host}:${port}`); 95 | 96 | // Save the discovered connection 97 | discoveredHost = host; 98 | discoveredPort = port; 99 | serverDiscovered = true; 100 | 101 | return true; 102 | } 103 | } 104 | } catch (error: any) { 105 | // Ignore connection errors during discovery 106 | console.error(`Error checking ${host}:${port}: ${error.message}`); 107 | } 108 | } 109 | } 110 | 111 | console.error("No server found during discovery"); 112 | return false; 113 | } 114 | 115 | // Wrapper function to ensure server connection before making requests 116 | async function withServerConnection<T>( 117 | apiCall: () => Promise<T> 118 | ): Promise<T | any> { 119 | // Attempt to discover server if not already discovered 120 | if (!serverDiscovered) { 121 | const discovered = await discoverServer(); 122 | if (!discovered) { 123 | return { 124 | content: [ 125 | { 126 | type: "text", 127 | text: "Failed to discover browser connector server. Please ensure it's running.", 128 | }, 129 | ], 130 | isError: true, 131 | }; 132 | } 133 | } 134 | 135 | // Now make the actual API call with discovered host/port 136 | try { 137 | return await apiCall(); 138 | } catch (error: any) { 139 | // If the request fails, try rediscovering the server once 140 | console.error( 141 | `API call failed: ${error.message}. Attempting rediscovery...` 142 | ); 143 | serverDiscovered = false; 144 | 145 | if (await discoverServer()) { 146 | console.error("Rediscovery successful. Retrying API call..."); 147 | try { 148 | // Retry the API call with the newly discovered connection 149 | return await apiCall(); 150 | } catch (retryError: any) { 151 | console.error(`Retry failed: ${retryError.message}`); 152 | return { 153 | content: [ 154 | { 155 | type: "text", 156 | text: `Error after reconnection attempt: ${retryError.message}`, 157 | }, 158 | ], 159 | isError: true, 160 | }; 161 | } 162 | } else { 163 | console.error("Rediscovery failed. Could not reconnect to server."); 164 | return { 165 | content: [ 166 | { 167 | type: "text", 168 | text: `Failed to reconnect to server: ${error.message}`, 169 | }, 170 | ], 171 | isError: true, 172 | }; 173 | } 174 | } 175 | } 176 | 177 | // We'll define our tools that retrieve data from the browser connector 178 | server.tool("getConsoleLogs", "Check our browser logs", async () => { 179 | return await withServerConnection(async () => { 180 | const response = await fetch( 181 | `http://${discoveredHost}:${discoveredPort}/console-logs` 182 | ); 183 | const json = await response.json(); 184 | return { 185 | content: [ 186 | { 187 | type: "text", 188 | text: JSON.stringify(json, null, 2), 189 | }, 190 | ], 191 | }; 192 | }); 193 | }); 194 | 195 | server.tool( 196 | "getConsoleErrors", 197 | "Check our browsers console errors", 198 | async () => { 199 | return await withServerConnection(async () => { 200 | const response = await fetch( 201 | `http://${discoveredHost}:${discoveredPort}/console-errors` 202 | ); 203 | const json = await response.json(); 204 | return { 205 | content: [ 206 | { 207 | type: "text", 208 | text: JSON.stringify(json, null, 2), 209 | }, 210 | ], 211 | }; 212 | }); 213 | } 214 | ); 215 | 216 | server.tool("getNetworkErrors", "Check our network ERROR logs", async () => { 217 | return await withServerConnection(async () => { 218 | const response = await fetch( 219 | `http://${discoveredHost}:${discoveredPort}/network-errors` 220 | ); 221 | const json = await response.json(); 222 | return { 223 | content: [ 224 | { 225 | type: "text", 226 | text: JSON.stringify(json, null, 2), 227 | }, 228 | ], 229 | isError: true, 230 | }; 231 | }); 232 | }); 233 | 234 | server.tool("getNetworkLogs", "Check ALL our network logs", async () => { 235 | return await withServerConnection(async () => { 236 | const response = await fetch( 237 | `http://${discoveredHost}:${discoveredPort}/network-success` 238 | ); 239 | const json = await response.json(); 240 | return { 241 | content: [ 242 | { 243 | type: "text", 244 | text: JSON.stringify(json, null, 2), 245 | }, 246 | ], 247 | }; 248 | }); 249 | }); 250 | 251 | server.tool( 252 | "takeScreenshot", 253 | "Take a screenshot of the current browser tab", 254 | async () => { 255 | return await withServerConnection(async () => { 256 | try { 257 | const response = await fetch( 258 | `http://${discoveredHost}:${discoveredPort}/capture-screenshot`, 259 | { 260 | method: "POST", 261 | } 262 | ); 263 | 264 | const result = await response.json(); 265 | 266 | if (response.ok) { 267 | return { 268 | content: [ 269 | { 270 | type: "text", 271 | text: "Successfully saved screenshot", 272 | }, 273 | ], 274 | }; 275 | } else { 276 | return { 277 | content: [ 278 | { 279 | type: "text", 280 | text: `Error taking screenshot: ${result.error}`, 281 | }, 282 | ], 283 | }; 284 | } 285 | } catch (error: any) { 286 | const errorMessage = 287 | error instanceof Error ? error.message : String(error); 288 | return { 289 | content: [ 290 | { 291 | type: "text", 292 | text: `Failed to take screenshot: ${errorMessage}`, 293 | }, 294 | ], 295 | }; 296 | } 297 | }); 298 | } 299 | ); 300 | 301 | server.tool( 302 | "getSelectedElement", 303 | "Get the selected element from the browser", 304 | async () => { 305 | return await withServerConnection(async () => { 306 | const response = await fetch( 307 | `http://${discoveredHost}:${discoveredPort}/selected-element` 308 | ); 309 | const json = await response.json(); 310 | return { 311 | content: [ 312 | { 313 | type: "text", 314 | text: JSON.stringify(json, null, 2), 315 | }, 316 | ], 317 | }; 318 | }); 319 | } 320 | ); 321 | 322 | server.tool("wipeLogs", "Wipe all browser logs from memory", async () => { 323 | return await withServerConnection(async () => { 324 | const response = await fetch( 325 | `http://${discoveredHost}:${discoveredPort}/wipelogs`, 326 | { 327 | method: "POST", 328 | } 329 | ); 330 | const json = await response.json(); 331 | return { 332 | content: [ 333 | { 334 | type: "text", 335 | text: json.message, 336 | }, 337 | ], 338 | }; 339 | }); 340 | }); 341 | 342 | // Define audit categories as enum to match the server's AuditCategory enum 343 | enum AuditCategory { 344 | ACCESSIBILITY = "accessibility", 345 | PERFORMANCE = "performance", 346 | SEO = "seo", 347 | BEST_PRACTICES = "best-practices", 348 | PWA = "pwa", 349 | } 350 | 351 | // Add tool for accessibility audits, launches a headless browser instance 352 | server.tool( 353 | "runAccessibilityAudit", 354 | "Run an accessibility audit on the current page", 355 | {}, 356 | async () => { 357 | return await withServerConnection(async () => { 358 | try { 359 | // Simplified approach - let the browser connector handle the current tab and URL 360 | console.log( 361 | `Sending POST request to http://${discoveredHost}:${discoveredPort}/accessibility-audit` 362 | ); 363 | const response = await fetch( 364 | `http://${discoveredHost}:${discoveredPort}/accessibility-audit`, 365 | { 366 | method: "POST", 367 | headers: { 368 | "Content-Type": "application/json", 369 | Accept: "application/json", 370 | }, 371 | body: JSON.stringify({ 372 | category: AuditCategory.ACCESSIBILITY, 373 | source: "mcp_tool", 374 | timestamp: Date.now(), 375 | }), 376 | } 377 | ); 378 | 379 | // Log the response status 380 | console.log(`Accessibility audit response status: ${response.status}`); 381 | 382 | if (!response.ok) { 383 | const errorText = await response.text(); 384 | console.error(`Accessibility audit error: ${errorText}`); 385 | throw new Error(`Server returned ${response.status}: ${errorText}`); 386 | } 387 | 388 | const json = await response.json(); 389 | 390 | // flatten it by merging metadata with the report contents 391 | if (json.report) { 392 | const { metadata, report } = json; 393 | const flattened = { 394 | ...metadata, 395 | ...report, 396 | }; 397 | 398 | return { 399 | content: [ 400 | { 401 | type: "text", 402 | text: JSON.stringify(flattened, null, 2), 403 | }, 404 | ], 405 | }; 406 | } else { 407 | // Return as-is if it's not in the new format 408 | return { 409 | content: [ 410 | { 411 | type: "text", 412 | text: JSON.stringify(json, null, 2), 413 | }, 414 | ], 415 | }; 416 | } 417 | } catch (error) { 418 | const errorMessage = 419 | error instanceof Error ? error.message : String(error); 420 | console.error("Error in accessibility audit:", errorMessage); 421 | return { 422 | content: [ 423 | { 424 | type: "text", 425 | text: `Failed to run accessibility audit: ${errorMessage}`, 426 | }, 427 | ], 428 | }; 429 | } 430 | }); 431 | } 432 | ); 433 | 434 | // Add tool for performance audits, launches a headless browser instance 435 | server.tool( 436 | "runPerformanceAudit", 437 | "Run a performance audit on the current page", 438 | {}, 439 | async () => { 440 | return await withServerConnection(async () => { 441 | try { 442 | // Simplified approach - let the browser connector handle the current tab and URL 443 | console.log( 444 | `Sending POST request to http://${discoveredHost}:${discoveredPort}/performance-audit` 445 | ); 446 | const response = await fetch( 447 | `http://${discoveredHost}:${discoveredPort}/performance-audit`, 448 | { 449 | method: "POST", 450 | headers: { 451 | "Content-Type": "application/json", 452 | Accept: "application/json", 453 | }, 454 | body: JSON.stringify({ 455 | category: AuditCategory.PERFORMANCE, 456 | source: "mcp_tool", 457 | timestamp: Date.now(), 458 | }), 459 | } 460 | ); 461 | 462 | // Log the response status 463 | console.log(`Performance audit response status: ${response.status}`); 464 | 465 | if (!response.ok) { 466 | const errorText = await response.text(); 467 | console.error(`Performance audit error: ${errorText}`); 468 | throw new Error(`Server returned ${response.status}: ${errorText}`); 469 | } 470 | 471 | const json = await response.json(); 472 | 473 | // flatten it by merging metadata with the report contents 474 | if (json.report) { 475 | const { metadata, report } = json; 476 | const flattened = { 477 | ...metadata, 478 | ...report, 479 | }; 480 | 481 | return { 482 | content: [ 483 | { 484 | type: "text", 485 | text: JSON.stringify(flattened, null, 2), 486 | }, 487 | ], 488 | }; 489 | } else { 490 | // Return as-is if it's not in the new format 491 | return { 492 | content: [ 493 | { 494 | type: "text", 495 | text: JSON.stringify(json, null, 2), 496 | }, 497 | ], 498 | }; 499 | } 500 | } catch (error) { 501 | const errorMessage = 502 | error instanceof Error ? error.message : String(error); 503 | console.error("Error in performance audit:", errorMessage); 504 | return { 505 | content: [ 506 | { 507 | type: "text", 508 | text: `Failed to run performance audit: ${errorMessage}`, 509 | }, 510 | ], 511 | }; 512 | } 513 | }); 514 | } 515 | ); 516 | 517 | // Add tool for SEO audits, launches a headless browser instance 518 | server.tool( 519 | "runSEOAudit", 520 | "Run an SEO audit on the current page", 521 | {}, 522 | async () => { 523 | return await withServerConnection(async () => { 524 | try { 525 | console.log( 526 | `Sending POST request to http://${discoveredHost}:${discoveredPort}/seo-audit` 527 | ); 528 | const response = await fetch( 529 | `http://${discoveredHost}:${discoveredPort}/seo-audit`, 530 | { 531 | method: "POST", 532 | headers: { 533 | "Content-Type": "application/json", 534 | Accept: "application/json", 535 | }, 536 | body: JSON.stringify({ 537 | category: AuditCategory.SEO, 538 | source: "mcp_tool", 539 | timestamp: Date.now(), 540 | }), 541 | } 542 | ); 543 | 544 | // Log the response status 545 | console.log(`SEO audit response status: ${response.status}`); 546 | 547 | if (!response.ok) { 548 | const errorText = await response.text(); 549 | console.error(`SEO audit error: ${errorText}`); 550 | throw new Error(`Server returned ${response.status}: ${errorText}`); 551 | } 552 | 553 | const json = await response.json(); 554 | 555 | return { 556 | content: [ 557 | { 558 | type: "text", 559 | text: JSON.stringify(json, null, 2), 560 | }, 561 | ], 562 | }; 563 | } catch (error) { 564 | const errorMessage = 565 | error instanceof Error ? error.message : String(error); 566 | console.error("Error in SEO audit:", errorMessage); 567 | return { 568 | content: [ 569 | { 570 | type: "text", 571 | text: `Failed to run SEO audit: ${errorMessage}`, 572 | }, 573 | ], 574 | }; 575 | } 576 | }); 577 | } 578 | ); 579 | 580 | server.tool("runNextJSAudit", {}, async () => ({ 581 | content: [ 582 | { 583 | type: "text", 584 | text: ` 585 | You are an expert in SEO and web development with NextJS. Given the following procedures for analyzing my codebase, please perform a comprehensive - page by page analysis of our NextJS application to identify any issues or areas of improvement for SEO. 586 | 587 | After each iteration of changes, reinvoke this tool to re-fetch our SEO audit procedures and then scan our codebase again to identify additional areas of improvement. 588 | 589 | When no more areas of improvement are found, return "No more areas of improvement found, your NextJS application is optimized for SEO!". 590 | 591 | Start by analyzing each of the following aspects of our codebase: 592 | 1. Meta tags - provides information about your website to search engines and social media platforms. 593 | 594 | Pages should provide the following standard meta tags: 595 | 596 | title 597 | description 598 | keywords 599 | robots 600 | viewport 601 | charSet 602 | Open Graph meta tags: 603 | 604 | og:site_name 605 | og:locale 606 | og:title 607 | og:description 608 | og:type 609 | og:url 610 | og:image 611 | og:image:alt 612 | og:image:type 613 | og:image:width 614 | og:image:height 615 | Article meta tags (actually it's also OpenGraph): 616 | 617 | article:published_time 618 | article:modified_time 619 | article:author 620 | Twitter meta tags: 621 | 622 | twitter:card 623 | twitter:site 624 | twitter:creator 625 | twitter:title 626 | twitter:description 627 | twitter:image 628 | 629 | For applications using the pages router, set up metatags like this in pages/[slug].tsx: 630 | import Head from "next/head"; 631 | 632 | export default function Page() { 633 | return ( 634 | <Head> 635 | <title> 636 | Next.js SEO: The Complete Checklist to Boost Your Site Ranking 637 | </title> 638 | <meta 639 | name="description" 640 | content="Learn how to optimize your Next.js website for SEO by following this complete checklist." 641 | /> 642 | <meta 643 | name="keywords" 644 | content="nextjs seo complete checklist, nextjs seo tutorial" 645 | /> 646 | <meta name="robots" content="index, follow" /> 647 | <meta name="googlebot" content="index, follow" /> 648 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 649 | <meta charSet="utf-8" /> 650 | <meta property="og:site_name" content="Blog | Minh Vu" /> 651 | <meta property="og:locale" content="en_US" /> 652 | <meta 653 | property="og:title" 654 | content="Next.js SEO: The Complete Checklist to Boost Your Site Ranking" 655 | /> 656 | <meta 657 | property="og:description" 658 | content="Learn how to optimize your Next.js website for SEO by following this complete checklist." 659 | /> 660 | <meta property="og:type" content="website" /> 661 | <meta property="og:url" content="https://dminhvu.com/nextjs-seo" /> 662 | <meta 663 | property="og:image" 664 | content="https://ik.imagekit.io/dminhvu/assets/nextjs-seo/thumbnail.png?tr=f-png" 665 | /> 666 | <meta property="og:image:alt" content="Next.js SEO" /> 667 | <meta property="og:image:type" content="image/png" /> 668 | <meta property="og:image:width" content="1200" /> 669 | <meta property="og:image:height" content="630" /> 670 | <meta 671 | property="article:published_time" 672 | content="2024-01-11T11:35:00+07:00" 673 | /> 674 | <meta 675 | property="article:modified_time" 676 | content="2024-01-11T11:35:00+07:00" 677 | /> 678 | <meta 679 | property="article:author" 680 | content="https://www.linkedin.com/in/dminhvu02" 681 | /> 682 | <meta name="twitter:card" content="summary_large_image" /> 683 | <meta name="twitter:site" content="@dminhvu02" /> 684 | <meta name="twitter:creator" content="@dminhvu02" /> 685 | <meta 686 | name="twitter:title" 687 | content="Next.js SEO: The Complete Checklist to Boost Your Site Ranking" 688 | /> 689 | <meta 690 | name="twitter:description" 691 | content="Learn how to optimize your Next.js website for SEO by following this complete checklist." 692 | /> 693 | <meta 694 | name="twitter:image" 695 | content="https://ik.imagekit.io/dminhvu/assets/nextjs-seo/thumbnail.png?tr=f-png" 696 | /> 697 | </Head> 698 | ); 699 | } 700 | 701 | For applications using the app router, set up metatags like this in layout.tsx: 702 | import type { Viewport, Metadata } from "next"; 703 | 704 | export const viewport: Viewport = { 705 | width: "device-width", 706 | initialScale: 1, 707 | themeColor: "#ffffff" 708 | }; 709 | 710 | export const metadata: Metadata = { 711 | metadataBase: new URL("https://dminhvu.com"), 712 | openGraph: { 713 | siteName: "Blog | Minh Vu", 714 | type: "website", 715 | locale: "en_US" 716 | }, 717 | robots: { 718 | index: true, 719 | follow: true, 720 | "max-image-preview": "large", 721 | "max-snippet": -1, 722 | "max-video-preview": -1, 723 | googleBot: "index, follow" 724 | }, 725 | alternates: { 726 | types: { 727 | "application/rss+xml": "https://dminhvu.com/rss.xml" 728 | } 729 | }, 730 | applicationName: "Blog | Minh Vu", 731 | appleWebApp: { 732 | title: "Blog | Minh Vu", 733 | statusBarStyle: "default", 734 | capable: true 735 | }, 736 | verification: { 737 | google: "YOUR_DATA", 738 | yandex: ["YOUR_DATA"], 739 | other: { 740 | "msvalidate.01": ["YOUR_DATA"], 741 | "facebook-domain-verification": ["YOUR_DATA"] 742 | } 743 | }, 744 | icons: { 745 | icon: [ 746 | { 747 | url: "/favicon.ico", 748 | type: "image/x-icon" 749 | }, 750 | { 751 | url: "/favicon-16x16.png", 752 | sizes: "16x16", 753 | type: "image/png" 754 | } 755 | // add favicon-32x32.png, favicon-96x96.png, android-chrome-192x192.png 756 | ], 757 | shortcut: [ 758 | { 759 | url: "/favicon.ico", 760 | type: "image/x-icon" 761 | } 762 | ], 763 | apple: [ 764 | { 765 | url: "/apple-icon-57x57.png", 766 | sizes: "57x57", 767 | type: "image/png" 768 | }, 769 | { 770 | url: "/apple-icon-60x60.png", 771 | sizes: "60x60", 772 | type: "image/png" 773 | } 774 | // add apple-icon-72x72.png, apple-icon-76x76.png, apple-icon-114x114.png, apple-icon-120x120.png, apple-icon-144x144.png, apple-icon-152x152.png, apple-icon-180x180.png 775 | ] 776 | } 777 | }; 778 | And like this for any page.tsx file: 779 | import { Metadata } from "next"; 780 | 781 | export const metadata: Metadata = { 782 | title: "Elastic Stack, Next.js, Python, JavaScript Tutorials | dminhvu", 783 | description: 784 | "dminhvu.com - Programming blog for everyone to learn Elastic Stack, Next.js, Python, JavaScript, React, Machine Learning, Data Science, and more.", 785 | keywords: [ 786 | "elastic", 787 | "python", 788 | "javascript", 789 | "react", 790 | "machine learning", 791 | "data science" 792 | ], 793 | openGraph: { 794 | url: "https://dminhvu.com", 795 | type: "website", 796 | title: "Elastic Stack, Next.js, Python, JavaScript Tutorials | dminhvu", 797 | description: 798 | "dminhvu.com - Programming blog for everyone to learn Elastic Stack, Next.js, Python, JavaScript, React, Machine Learning, Data Science, and more.", 799 | images: [ 800 | { 801 | url: "https://dminhvu.com/images/home/thumbnail.png", 802 | width: 1200, 803 | height: 630, 804 | alt: "dminhvu" 805 | } 806 | ] 807 | }, 808 | twitter: { 809 | card: "summary_large_image", 810 | title: "Elastic Stack, Next.js, Python, JavaScript Tutorials | dminhvu", 811 | description: 812 | "dminhvu.com - Programming blog for everyone to learn Elastic Stack, Next.js, Python, JavaScript, React, Machine Learning, Data Science, and more.", 813 | creator: "@dminhvu02", 814 | site: "@dminhvu02", 815 | images: [ 816 | { 817 | url: "https://dminhvu.com/images/home/thumbnail.png", 818 | width: 1200, 819 | height: 630, 820 | alt: "dminhvu" 821 | } 822 | ] 823 | }, 824 | alternates: { 825 | canonical: "https://dminhvu.com" 826 | } 827 | }; 828 | 829 | Note that the charSet and viewport are automatically added by Next.js App Router, so you don't need to define them. 830 | 831 | For applications using the app router, dynamic metadata can be defined by using the generateMetadata function, this is useful when you have dynamic pages like [slug]/page.tsx, or [id]/page.tsx: 832 | 833 | import type { Metadata, ResolvingMetadata } from "next"; 834 | 835 | type Params = { 836 | slug: string; 837 | }; 838 | 839 | type Props = { 840 | params: Params; 841 | searchParams: { [key: string]: string | string[] | undefined }; 842 | }; 843 | 844 | export async function generateMetadata( 845 | { params, searchParams }: Props, 846 | parent: ResolvingMetadata 847 | ): Promise<Metadata> { 848 | const { slug } = params; 849 | 850 | const post: Post = await fetch("YOUR_ENDPOINT", { 851 | method: "GET", 852 | next: { 853 | revalidate: 60 * 60 * 24 854 | } 855 | }).then((res) => res.json()); 856 | 857 | return { 858 | title: "{post.title} | dminhvu", 859 | authors: [ 860 | { 861 | name: post.author || "Minh Vu" 862 | } 863 | ], 864 | description: post.description, 865 | keywords: post.keywords, 866 | openGraph: { 867 | title: "{post.title} | dminhvu", 868 | description: post.description, 869 | type: "article", 870 | url: "https://dminhvu.com/{post.slug}", 871 | publishedTime: post.created_at, 872 | modifiedTime: post.modified_at, 873 | authors: ["https://dminhvu.com/about"], 874 | tags: post.categories, 875 | images: [ 876 | { 877 | url: "https://ik.imagekit.io/dminhvu/assets/{post.slug}/thumbnail.png?tr=f-png", 878 | width: 1024, 879 | height: 576, 880 | alt: post.title, 881 | type: "image/png" 882 | } 883 | ] 884 | }, 885 | twitter: { 886 | card: "summary_large_image", 887 | site: "@dminhvu02", 888 | creator: "@dminhvu02", 889 | title: "{post.title} | dminhvu", 890 | description: post.description, 891 | images: [ 892 | { 893 | url: "https://ik.imagekit.io/dminhvu/assets/{post.slug}/thumbnail.png?tr=f-png", 894 | width: 1024, 895 | height: 576, 896 | alt: post.title 897 | } 898 | ] 899 | }, 900 | alternates: { 901 | canonical: "https://dminhvu.com/{post.slug}" 902 | } 903 | }; 904 | } 905 | 906 | 907 | 2. JSON-LD Schema 908 | 909 | JSON-LD is a format for structured data that can be used by search engines to understand your content. For example, you can use it to describe a person, an event, an organization, a movie, a book, a recipe, and many other types of entities. 910 | 911 | Our current recommendation for JSON-LD is to render structured data as a <script> tag in your layout.js or page.js components. For example: 912 | export default async function Page({ params }) { 913 | const { id } = await params 914 | const product = await getProduct(id) 915 | 916 | const jsonLd = { 917 | '@context': 'https://schema.org', 918 | '@type': 'Product', 919 | name: product.name, 920 | image: product.image, 921 | description: product.description, 922 | } 923 | 924 | return ( 925 | <section> 926 | {/* Add JSON-LD to your page */} 927 | <script 928 | type="application/ld+json" 929 | dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} 930 | /> 931 | {/* ... */} 932 | </section> 933 | ) 934 | } 935 | 936 | You can type your JSON-LD with TypeScript using community packages like schema-dts: 937 | 938 | 939 | import { Product, WithContext } from 'schema-dts' 940 | 941 | const jsonLd: WithContext<Product> = { 942 | '@context': 'https://schema.org', 943 | '@type': 'Product', 944 | name: 'Next.js Sticker', 945 | image: 'https://nextjs.org/imgs/sticker.png', 946 | description: 'Dynamic at the speed of static.', 947 | } 948 | 3. Sitemap 949 | Your website should provide a sitemap so that search engines can easily crawl and index your pages. 950 | 951 | Generate Sitemap for Next.js Pages Router 952 | For Next.js Pages Router, you can use next-sitemap to generate a sitemap for your Next.js website after building. 953 | 954 | For example, running the following command will install next-sitemap and generate a sitemap for this blog: 955 | 956 | 957 | npm install next-sitemap 958 | npx next-sitemap 959 | A sitemap will be generated at public/sitemap.xml: 960 | 961 | public/sitemap.xml 962 | 963 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"> 964 | <url> 965 | <loc>https://dminhvu.com</loc> 966 | <lastmod>2024-01-11T02:03:09.613Z</lastmod> 967 | <changefreq>daily</changefreq> 968 | <priority>0.7</priority> 969 | </url> 970 | <!-- other pages --> 971 | </urlset> 972 | Please visit the next-sitemap page for more information. 973 | 974 | Generate Sitemap for Next.js App Router 975 | For Next.js App Router, you can define the sitemap.ts file at app/sitemap.ts: 976 | 977 | app/sitemap.ts 978 | 979 | import { 980 | getAllCategories, 981 | getAllPostSlugsWithModifyTime 982 | } from "@/utils/getData"; 983 | import { MetadataRoute } from "next"; 984 | 985 | export default async function sitemap(): Promise<MetadataRoute.Sitemap> { 986 | const defaultPages = [ 987 | { 988 | url: "https://dminhvu.com", 989 | lastModified: new Date(), 990 | changeFrequency: "daily", 991 | priority: 1 992 | }, 993 | { 994 | url: "https://dminhvu.com/about", 995 | lastModified: new Date(), 996 | changeFrequency: "monthly", 997 | priority: 0.9 998 | }, 999 | { 1000 | url: "https://dminhvu.com/contact", 1001 | lastModified: new Date(), 1002 | changeFrequency: "monthly", 1003 | priority: 0.9 1004 | } 1005 | // other pages 1006 | ]; 1007 | 1008 | const postSlugs = await getAllPostSlugsWithModifyTime(); 1009 | const categorySlugs = await getAllCategories(); 1010 | 1011 | const sitemap = [ 1012 | ...defaultPages, 1013 | ...postSlugs.map((e: any) => ({ 1014 | url: "https://dminhvu.com/{e.slug}", 1015 | lastModified: e.modified_at, 1016 | changeFrequency: "daily", 1017 | priority: 0.8 1018 | })), 1019 | ...categorySlugs.map((e: any) => ({ 1020 | url: "https://dminhvu.com/category/{e}", 1021 | lastModified: new Date(), 1022 | changeFrequency: "daily", 1023 | priority: 0.7 1024 | })) 1025 | ]; 1026 | 1027 | return sitemap; 1028 | } 1029 | With this sitemap.ts file created, you can access the sitemap at https://dminhvu.com/sitemap.xml. 1030 | 1031 | 1032 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> 1033 | <url> 1034 | <loc>https://dminhvu.com</loc> 1035 | <lastmod>2024-01-11T02:03:09.613Z</lastmod> 1036 | <changefreq>daily</changefreq> 1037 | <priority>0.7</priority> 1038 | </url> 1039 | <!-- other pages --> 1040 | </urlset> 1041 | 4. robots.txt 1042 | A robots.txt file should be added to tell search engines which pages to crawl and which pages to ignore. 1043 | 1044 | robots.txt for Next.js Pages Router 1045 | For Next.js Pages Router, you can create a robots.txt file at public/robots.txt: 1046 | 1047 | public/robots.txt 1048 | 1049 | User-agent: * 1050 | Disallow: 1051 | Sitemap: https://dminhvu.com/sitemap.xml 1052 | You can prevent the search engine from crawling a page (usually search result pages, noindex pages, etc.) by adding the following line: 1053 | 1054 | public/robots.txt 1055 | 1056 | User-agent: * 1057 | Disallow: /search?q= 1058 | Disallow: /admin 1059 | robots.txt for Next.js App Router 1060 | For Next.js App Router, you don't need to manually define a robots.txt file. Instead, you can define the robots.ts file at app/robots.ts: 1061 | 1062 | app/robots.ts 1063 | 1064 | import { MetadataRoute } from "next"; 1065 | 1066 | export default function robots(): MetadataRoute.Robots { 1067 | return { 1068 | rules: { 1069 | userAgent: "*", 1070 | allow: ["/"], 1071 | disallow: ["/search?q=", "/admin/"] 1072 | }, 1073 | sitemap: ["https://dminhvu.com/sitemap.xml"] 1074 | }; 1075 | } 1076 | With this robots.ts file created, you can access the robots.txt file at https://dminhvu.com/robots.txt. 1077 | 1078 | 1079 | User-agent: * 1080 | Allow: / 1081 | Disallow: /search?q= 1082 | Disallow: /admin 1083 | 1084 | Sitemap: https://dminhvu.com/sitemap.xml 1085 | 5. Link tags 1086 | Link Tags for Next.js Pages Router 1087 | For example, the current page has the following link tags if I use the Pages Router: 1088 | 1089 | pages/_app.tsx 1090 | 1091 | import Head from "next/head"; 1092 | 1093 | export default function Page() { 1094 | return ( 1095 | <Head> 1096 | {/* other parts */} 1097 | <link 1098 | rel="alternate" 1099 | type="application/rss+xml" 1100 | href="https://dminhvu.com/rss.xml" 1101 | /> 1102 | <link rel="icon" href="/favicon.ico" type="image/x-icon" /> 1103 | <link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png" /> 1104 | <link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png" /> 1105 | {/* add apple-touch-icon-72x72.png, apple-touch-icon-76x76.png, apple-touch-icon-114x114.png, apple-touch-icon-120x120.png, apple-touch-icon-144x144.png, apple-touch-icon-152x152.png, apple-touch-icon-180x180.png */} 1106 | <link 1107 | rel="icon" 1108 | type="image/png" 1109 | href="/favicon-16x16.png" 1110 | sizes="16x16" 1111 | /> 1112 | {/* add favicon-32x32.png, favicon-96x96.png, android-chrome-192x192.png */} 1113 | </Head> 1114 | ); 1115 | } 1116 | pages/[slug].tsx 1117 | 1118 | import Head from "next/head"; 1119 | 1120 | export default function Page() { 1121 | return ( 1122 | <Head> 1123 | {/* other parts */} 1124 | <link rel="canonical" href="https://dminhvu.com/nextjs-seo" /> 1125 | </Head> 1126 | ); 1127 | } 1128 | Link Tags for Next.js App Router 1129 | For Next.js App Router, the link tags can be defined using the export const metadata or generateMetadata similar to the meta tags section. 1130 | 1131 | The code below is exactly the same as the meta tags for Next.js App Router section above. 1132 | 1133 | app/layout.tsx 1134 | 1135 | export const metadata: Metadata = { 1136 | // other parts 1137 | alternates: { 1138 | types: { 1139 | "application/rss+xml": "https://dminhvu.com/rss.xml" 1140 | } 1141 | }, 1142 | icons: { 1143 | icon: [ 1144 | { 1145 | url: "/favicon.ico", 1146 | type: "image/x-icon" 1147 | }, 1148 | { 1149 | url: "/favicon-16x16.png", 1150 | sizes: "16x16", 1151 | type: "image/png" 1152 | } 1153 | // add favicon-32x32.png, favicon-96x96.png, android-chrome-192x192.png 1154 | ], 1155 | shortcut: [ 1156 | { 1157 | url: "/favicon.ico", 1158 | type: "image/x-icon" 1159 | } 1160 | ], 1161 | apple: [ 1162 | { 1163 | url: "/apple-icon-57x57.png", 1164 | sizes: "57x57", 1165 | type: "image/png" 1166 | }, 1167 | { 1168 | url: "/apple-icon-60x60.png", 1169 | sizes: "60x60", 1170 | type: "image/png" 1171 | } 1172 | // add apple-icon-72x72.png, apple-icon-76x76.png, apple-icon-114x114.png, apple-icon-120x120.png, apple-icon-144x144.png, apple-icon-152x152.png, apple-icon-180x180.png 1173 | ] 1174 | } 1175 | }; 1176 | app/page.tsx 1177 | 1178 | export const metadata: Metadata = { 1179 | // other parts 1180 | alternates: { 1181 | canonical: "https://dminhvu.com" 1182 | } 1183 | }; 1184 | 6. Script optimization 1185 | Script Optimization for General Scripts 1186 | Next.js provides a built-in component called <Script> to add external scripts to your website. 1187 | 1188 | For example, you can add Google Analytics to your website by adding the following script tag: 1189 | 1190 | pages/_app.tsx 1191 | 1192 | import Head from "next/head"; 1193 | import Script from "next/script"; 1194 | 1195 | export default function Page() { 1196 | return ( 1197 | <Head> 1198 | {/* other parts */} 1199 | {process.env.NODE_ENV === "production" && ( 1200 | <> 1201 | <Script async strategy="afterInteractive" id="analytics"> 1202 | {' 1203 | window.dataLayer = window.dataLayer || []; 1204 | function gtag(){dataLayer.push(arguments);} 1205 | gtag('js', new Date()); 1206 | gtag('config', 'G-XXXXXXXXXX'); 1207 | '} 1208 | </Script> 1209 | </> 1210 | )} 1211 | </Head> 1212 | ); 1213 | } 1214 | Script Optimization for Common Third-Party Integrations 1215 | Next.js App Router introduces a new library called @next/third-parties for: 1216 | 1217 | Google Tag Manager 1218 | Google Analytics 1219 | Google Maps Embed 1220 | YouTube Embed 1221 | To use the @next/third-parties library, you need to install it: 1222 | 1223 | 1224 | npm install @next/third-parties 1225 | Then, you can add the following code to your app/layout.tsx: 1226 | 1227 | app/layout.tsx 1228 | 1229 | import { GoogleTagManager } from "@next/third-parties/google"; 1230 | import { GoogleAnalytics } from "@next/third-parties/google"; 1231 | import Head from "next/head"; 1232 | 1233 | export default function Page() { 1234 | return ( 1235 | <html lang="en" className="scroll-smooth" suppressHydrationWarning> 1236 | {process.env.NODE_ENV === "production" && ( 1237 | <> 1238 | <GoogleAnalytics gaId="G-XXXXXXXXXX" /> 1239 | {/* other scripts */} 1240 | </> 1241 | )} 1242 | {/* other parts */} 1243 | </html> 1244 | ); 1245 | } 1246 | Please note that you don't need to include both GoogleTagManager and GoogleAnalytics if you only use one of them. 1247 | 7. Image optimization 1248 | Image Optimization 1249 | This part can be applied to both Pages Router and App Router. 1250 | 1251 | Image optimization is also an important part of SEO as it helps your website load faster. 1252 | 1253 | Faster image rendering speed will contribute to the Google PageSpeed score, which can improve user experience and SEO. 1254 | 1255 | You can use next/image to optimize images in your Next.js website. 1256 | 1257 | For example, the following code will optimize this post thumbnail: 1258 | 1259 | 1260 | import Image from "next/image"; 1261 | 1262 | export default function Page() { 1263 | return ( 1264 | <Image 1265 | src="https://ik.imagekit.io/dminhvu/assets/nextjs-seo/thumbnail.png?tr=f-webp" 1266 | alt="Next.js SEO" 1267 | width={1200} 1268 | height={630} 1269 | /> 1270 | ); 1271 | } 1272 | Remember to use a CDN to serve your media (images, videos, etc.) to improve the loading speed. 1273 | 1274 | For the image format, use WebP if possible because it has a smaller size than PNG and JPEG. 1275 | 1276 | Given the provided procedures, begin by analyzing all of our Next.js pages. 1277 | Check to see what metadata already exists, look for any robot.txt files, and take a closer look at some of the other aspects of our project to determine areas of improvement. 1278 | Once you've performed this comprehensive analysis, return back a report on what we can do to improve our application. 1279 | Do not actually make the code changes yet, just return a comprehensive plan that you will ask for approval for. 1280 | If feedback is provided, adjust the plan accordingly and ask for approval again. 1281 | If the user approves of the plan, go ahead and proceed to implement all the necessary code changes to completely optimize our application. 1282 | `, 1283 | }, 1284 | ], 1285 | })); 1286 | 1287 | server.tool( 1288 | "runDebuggerMode", 1289 | "Run debugger mode to debug an issue in our application", 1290 | async () => ({ 1291 | content: [ 1292 | { 1293 | type: "text", 1294 | text: ` 1295 | Please follow this exact sequence to debug an issue in our application: 1296 | 1297 | 1. Reflect on 5-7 different possible sources of the problem 1298 | 2. Distill those down to 1-2 most likely sources 1299 | 3. Add additional logs to validate your assumptions and track the transformation of data structures throughout the application control flow before we move onto implementing the actual code fix 1300 | 4. Use the "getConsoleLogs", "getConsoleErrors", "getNetworkLogs" & "getNetworkErrors" tools to obtain any newly added web browser logs 1301 | 5. Obtain the server logs as well if accessible - otherwise, ask me to copy/paste them into the chat 1302 | 6. Deeply reflect on what could be wrong + produce a comprehensive analysis of the issue 1303 | 7. Suggest additional logs if the issue persists or if the source is not yet clear 1304 | 8. Once a fix is implemented, ask for approval to remove the previously added logs 1305 | 1306 | Note: DO NOT run any of our audits (runAccessibilityAudit, runPerformanceAudit, runBestPracticesAudit, runSEOAudit, runNextJSAudit) when in debugging mode unless explicitly asked to do so or unless you switch to audit mode. 1307 | `, 1308 | }, 1309 | ], 1310 | }) 1311 | ); 1312 | 1313 | server.tool( 1314 | "runAuditMode", 1315 | "Run audit mode to optimize our application for SEO, accessibility and performance", 1316 | async () => ({ 1317 | content: [ 1318 | { 1319 | type: "text", 1320 | text: ` 1321 | I want you to enter "Audit Mode". Use the following MCP tools one after the other in this exact sequence: 1322 | 1323 | 1. runAccessibilityAudit 1324 | 2. runPerformanceAudit 1325 | 3. runBestPracticesAudit 1326 | 4. runSEOAudit 1327 | 5. runNextJSAudit (only if our application is ACTUALLY using NextJS) 1328 | 1329 | After running all of these tools, return back a comprehensive analysis of the audit results. 1330 | 1331 | Do NOT use runNextJSAudit tool unless you see that our application is ACTUALLY using NextJS. 1332 | 1333 | DO NOT use the takeScreenshot tool EVER during audit mode. ONLY use it if I specifically ask you to take a screenshot of something. 1334 | 1335 | DO NOT check console or network logs to get started - your main priority is to run the audits in the sequence defined above. 1336 | 1337 | After returning an in-depth analysis, scan through my code and identify various files/parts of my codebase that we want to modify/improve based on the results of our audits. 1338 | 1339 | After identifying what changes may be needed, do NOT make the actual changes. Instead, return back a comprehensive, step-by-step plan to address all of these changes and ask for approval to execute this plan. If feedback is received, make a new plan and ask for approval again. If approved, execute the ENTIRE plan and after all phases/steps are complete, re-run the auditing tools in the same 4 step sequence again before returning back another analysis for additional changes potentially needed. 1340 | 1341 | Keep repeating / iterating through this process with the four tools until our application is as optimized as possible for SEO, accessibility and performance. 1342 | 1343 | `, 1344 | }, 1345 | ], 1346 | }) 1347 | ); 1348 | 1349 | // Add tool for Best Practices audits, launches a headless browser instance 1350 | server.tool( 1351 | "runBestPracticesAudit", 1352 | "Run a best practices audit on the current page", 1353 | {}, 1354 | async () => { 1355 | return await withServerConnection(async () => { 1356 | try { 1357 | console.log( 1358 | `Sending POST request to http://${discoveredHost}:${discoveredPort}/best-practices-audit` 1359 | ); 1360 | const response = await fetch( 1361 | `http://${discoveredHost}:${discoveredPort}/best-practices-audit`, 1362 | { 1363 | method: "POST", 1364 | headers: { 1365 | "Content-Type": "application/json", 1366 | Accept: "application/json", 1367 | }, 1368 | body: JSON.stringify({ 1369 | source: "mcp_tool", 1370 | timestamp: Date.now(), 1371 | }), 1372 | } 1373 | ); 1374 | 1375 | // Check for errors 1376 | if (!response.ok) { 1377 | const errorText = await response.text(); 1378 | throw new Error(`Server returned ${response.status}: ${errorText}`); 1379 | } 1380 | 1381 | const json = await response.json(); 1382 | 1383 | // flatten it by merging metadata with the report contents 1384 | if (json.report) { 1385 | const { metadata, report } = json; 1386 | const flattened = { 1387 | ...metadata, 1388 | ...report, 1389 | }; 1390 | 1391 | return { 1392 | content: [ 1393 | { 1394 | type: "text", 1395 | text: JSON.stringify(flattened, null, 2), 1396 | }, 1397 | ], 1398 | }; 1399 | } else { 1400 | // Return as-is if it's not in the new format 1401 | return { 1402 | content: [ 1403 | { 1404 | type: "text", 1405 | text: JSON.stringify(json, null, 2), 1406 | }, 1407 | ], 1408 | }; 1409 | } 1410 | } catch (error) { 1411 | const errorMessage = 1412 | error instanceof Error ? error.message : String(error); 1413 | console.error("Error in Best Practices audit:", errorMessage); 1414 | return { 1415 | content: [ 1416 | { 1417 | type: "text", 1418 | text: `Failed to run Best Practices audit: ${errorMessage}`, 1419 | }, 1420 | ], 1421 | }; 1422 | } 1423 | }); 1424 | } 1425 | ); 1426 | 1427 | // Start receiving messages on stdio 1428 | (async () => { 1429 | try { 1430 | // Attempt initial server discovery 1431 | console.error("Attempting initial server discovery on startup..."); 1432 | await discoverServer(); 1433 | if (serverDiscovered) { 1434 | console.error( 1435 | `Successfully discovered server at ${discoveredHost}:${discoveredPort}` 1436 | ); 1437 | } else { 1438 | console.error( 1439 | "Initial server discovery failed. Will try again when tools are used." 1440 | ); 1441 | } 1442 | 1443 | const transport = new StdioServerTransport(); 1444 | 1445 | // Ensure stdout is only used for JSON messages 1446 | const originalStdoutWrite = process.stdout.write.bind(process.stdout); 1447 | process.stdout.write = (chunk: any, encoding?: any, callback?: any) => { 1448 | // Only allow JSON messages to pass through 1449 | if (typeof chunk === "string" && !chunk.startsWith("{")) { 1450 | return true; // Silently skip non-JSON messages 1451 | } 1452 | return originalStdoutWrite(chunk, encoding, callback); 1453 | }; 1454 | 1455 | await server.connect(transport); 1456 | } catch (error) { 1457 | console.error("Failed to initialize MCP server:", error); 1458 | process.exit(1); 1459 | } 1460 | })(); 1461 | ```