This is page 3 of 3. Use http://codebase.md/sugatraj/cursor-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 ├── debugcommands.mdc ├── docs │ ├── mcp-docs.md │ └── mcp.md ├── LICENSE ├── package.json ├── README.md ├── SETUP_INSTRUCTIONS.md ├── start-servers.bat └── start-servers.sh ``` # Files -------------------------------------------------------------------------------- /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 | ``` -------------------------------------------------------------------------------- /browser-tools-server/browser-connector.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import express from "express"; 4 | import cors from "cors"; 5 | import bodyParser from "body-parser"; 6 | import { tokenizeAndEstimateCost } from "llm-cost"; 7 | import { WebSocketServer, WebSocket } from "ws"; 8 | import fs from "fs"; 9 | import path from "path"; 10 | import { IncomingMessage } from "http"; 11 | import { Socket } from "net"; 12 | import os from "os"; 13 | import { exec } from "child_process"; 14 | import { 15 | runPerformanceAudit, 16 | runAccessibilityAudit, 17 | runSEOAudit, 18 | AuditCategory, 19 | LighthouseReport, 20 | } from "./lighthouse/index.js"; 21 | import * as net from "net"; 22 | import { runBestPracticesAudit } from "./lighthouse/best-practices.js"; 23 | 24 | /** 25 | * Converts a file path to the appropriate format for the current platform 26 | * Handles Windows, WSL, macOS and Linux path formats 27 | * 28 | * @param inputPath - The path to convert 29 | * @returns The converted path appropriate for the current platform 30 | */ 31 | function convertPathForCurrentPlatform(inputPath: string): string { 32 | const platform = os.platform(); 33 | 34 | // If no path provided, return as is 35 | if (!inputPath) return inputPath; 36 | 37 | console.log(`Converting path "${inputPath}" for platform: ${platform}`); 38 | 39 | // Windows-specific conversion 40 | if (platform === "win32") { 41 | // Convert forward slashes to backslashes 42 | return inputPath.replace(/\//g, "\\"); 43 | } 44 | 45 | // Linux/Mac-specific conversion 46 | if (platform === "linux" || platform === "darwin") { 47 | // Check if this is a Windows UNC path (starts with \\) 48 | if (inputPath.startsWith("\\\\") || inputPath.includes("\\")) { 49 | // Check if this is a WSL path (contains wsl.localhost or wsl$) 50 | if (inputPath.includes("wsl.localhost") || inputPath.includes("wsl$")) { 51 | // Extract the path after the distribution name 52 | // Handle both \\wsl.localhost\Ubuntu\path and \\wsl$\Ubuntu\path formats 53 | const parts = inputPath.split("\\").filter((part) => part.length > 0); 54 | console.log("Path parts:", parts); 55 | 56 | // Find the index after the distribution name 57 | const distNames = [ 58 | "Ubuntu", 59 | "Debian", 60 | "kali", 61 | "openSUSE", 62 | "SLES", 63 | "Fedora", 64 | ]; 65 | 66 | // Find the distribution name in the path 67 | let distIndex = -1; 68 | for (const dist of distNames) { 69 | const index = parts.findIndex( 70 | (part) => part === dist || part.toLowerCase() === dist.toLowerCase() 71 | ); 72 | if (index !== -1) { 73 | distIndex = index; 74 | break; 75 | } 76 | } 77 | 78 | if (distIndex !== -1 && distIndex + 1 < parts.length) { 79 | // Reconstruct the path as a native Linux path 80 | const linuxPath = "/" + parts.slice(distIndex + 1).join("/"); 81 | console.log( 82 | `Converted Windows WSL path "${inputPath}" to Linux path "${linuxPath}"` 83 | ); 84 | return linuxPath; 85 | } 86 | 87 | // If we couldn't find a distribution name but it's clearly a WSL path, 88 | // try to extract everything after wsl.localhost or wsl$ 89 | const wslIndex = parts.findIndex( 90 | (part) => 91 | part === "wsl.localhost" || 92 | part === "wsl$" || 93 | part.toLowerCase() === "wsl.localhost" || 94 | part.toLowerCase() === "wsl$" 95 | ); 96 | 97 | if (wslIndex !== -1 && wslIndex + 2 < parts.length) { 98 | // Skip the WSL prefix and distribution name 99 | const linuxPath = "/" + parts.slice(wslIndex + 2).join("/"); 100 | console.log( 101 | `Converted Windows WSL path "${inputPath}" to Linux path "${linuxPath}"` 102 | ); 103 | return linuxPath; 104 | } 105 | } 106 | 107 | // For non-WSL Windows paths, just normalize the slashes 108 | const normalizedPath = inputPath 109 | .replace(/\\\\/g, "/") 110 | .replace(/\\/g, "/"); 111 | console.log( 112 | `Converted Windows UNC path "${inputPath}" to "${normalizedPath}"` 113 | ); 114 | return normalizedPath; 115 | } 116 | 117 | // Handle Windows drive letters (e.g., C:\path\to\file) 118 | if (/^[A-Z]:\\/i.test(inputPath)) { 119 | // Convert Windows drive path to Linux/Mac compatible path 120 | const normalizedPath = inputPath 121 | .replace(/^[A-Z]:\\/i, "/") 122 | .replace(/\\/g, "/"); 123 | console.log( 124 | `Converted Windows drive path "${inputPath}" to "${normalizedPath}"` 125 | ); 126 | return normalizedPath; 127 | } 128 | } 129 | 130 | // Return the original path if no conversion was needed or possible 131 | return inputPath; 132 | } 133 | 134 | // Function to get default downloads folder 135 | function getDefaultDownloadsFolder(): string { 136 | const homeDir = os.homedir(); 137 | // Downloads folder is typically the same path on Windows, macOS, and Linux 138 | const downloadsPath = path.join(homeDir, "Downloads", "mcp-screenshots"); 139 | return downloadsPath; 140 | } 141 | 142 | // We store logs in memory 143 | const consoleLogs: any[] = []; 144 | const consoleErrors: any[] = []; 145 | const networkErrors: any[] = []; 146 | const networkSuccess: any[] = []; 147 | const allXhr: any[] = []; 148 | 149 | // Store the current URL from the extension 150 | let currentUrl: string = ""; 151 | 152 | // Store the current tab ID from the extension 153 | let currentTabId: string | number | null = null; 154 | 155 | // Add settings state 156 | let currentSettings = { 157 | logLimit: 50, 158 | queryLimit: 30000, 159 | showRequestHeaders: false, 160 | showResponseHeaders: false, 161 | model: "claude-3-sonnet", 162 | stringSizeLimit: 500, 163 | maxLogSize: 20000, 164 | screenshotPath: getDefaultDownloadsFolder(), 165 | // Add server host configuration 166 | serverHost: process.env.SERVER_HOST || "0.0.0.0", // Default to all interfaces 167 | }; 168 | 169 | // Add new storage for selected element 170 | let selectedElement: any = null; 171 | 172 | // Add new state for tracking screenshot requests 173 | interface ScreenshotCallback { 174 | resolve: (value: { 175 | data: string; 176 | path?: string; 177 | autoPaste?: boolean; 178 | }) => void; 179 | reject: (reason: Error) => void; 180 | } 181 | 182 | const screenshotCallbacks = new Map<string, ScreenshotCallback>(); 183 | 184 | // Function to get available port starting with the given port 185 | async function getAvailablePort( 186 | startPort: number, 187 | maxAttempts: number = 10 188 | ): Promise<number> { 189 | let currentPort = startPort; 190 | let attempts = 0; 191 | 192 | while (attempts < maxAttempts) { 193 | try { 194 | // Try to create a server on the current port 195 | // We'll use a raw Node.js net server for just testing port availability 196 | await new Promise<void>((resolve, reject) => { 197 | const testServer = net.createServer(); 198 | 199 | // Handle errors (e.g., port in use) 200 | testServer.once("error", (err: any) => { 201 | if (err.code === "EADDRINUSE") { 202 | console.log(`Port ${currentPort} is in use, trying next port...`); 203 | currentPort++; 204 | attempts++; 205 | resolve(); // Continue to next iteration 206 | } else { 207 | reject(err); // Different error, propagate it 208 | } 209 | }); 210 | 211 | // If we can listen, the port is available 212 | testServer.once("listening", () => { 213 | // Make sure to close the server to release the port 214 | testServer.close(() => { 215 | console.log(`Found available port: ${currentPort}`); 216 | resolve(); 217 | }); 218 | }); 219 | 220 | // Try to listen on the current port 221 | testServer.listen(currentPort, currentSettings.serverHost); 222 | }); 223 | 224 | // If we reach here without incrementing the port, it means the port is available 225 | return currentPort; 226 | } catch (error: any) { 227 | console.error(`Error checking port ${currentPort}:`, error); 228 | // For non-EADDRINUSE errors, try the next port 229 | currentPort++; 230 | attempts++; 231 | } 232 | } 233 | 234 | // If we've exhausted all attempts, throw an error 235 | throw new Error( 236 | `Could not find an available port after ${maxAttempts} attempts starting from ${startPort}` 237 | ); 238 | } 239 | 240 | // Start with requested port and find an available one 241 | const REQUESTED_PORT = parseInt(process.env.PORT || "3025", 10); 242 | let PORT = REQUESTED_PORT; 243 | 244 | // Create application and initialize middleware 245 | const app = express(); 246 | app.use(cors()); 247 | // Increase JSON body parser limit to 50MB to handle large screenshots 248 | app.use(bodyParser.json({ limit: "50mb" })); 249 | app.use(bodyParser.urlencoded({ limit: "50mb", extended: true })); 250 | 251 | // Helper to recursively truncate strings in any data structure 252 | function truncateStringsInData(data: any, maxLength: number): any { 253 | if (typeof data === "string") { 254 | return data.length > maxLength 255 | ? data.substring(0, maxLength) + "... (truncated)" 256 | : data; 257 | } 258 | 259 | if (Array.isArray(data)) { 260 | return data.map((item) => truncateStringsInData(item, maxLength)); 261 | } 262 | 263 | if (typeof data === "object" && data !== null) { 264 | const result: any = {}; 265 | for (const [key, value] of Object.entries(data)) { 266 | result[key] = truncateStringsInData(value, maxLength); 267 | } 268 | return result; 269 | } 270 | 271 | return data; 272 | } 273 | 274 | // Helper to safely parse and process JSON strings 275 | function processJsonString(jsonString: string, maxLength: number): string { 276 | try { 277 | // Try to parse the string as JSON 278 | const parsed = JSON.parse(jsonString); 279 | // Process any strings within the parsed JSON 280 | const processed = truncateStringsInData(parsed, maxLength); 281 | // Stringify the processed data 282 | return JSON.stringify(processed); 283 | } catch (e) { 284 | // If it's not valid JSON, treat it as a regular string 285 | return truncateStringsInData(jsonString, maxLength); 286 | } 287 | } 288 | 289 | // Helper to process logs based on settings 290 | function processLogsWithSettings(logs: any[]) { 291 | return logs.map((log) => { 292 | const processedLog = { ...log }; 293 | 294 | if (log.type === "network-request") { 295 | // Handle headers visibility 296 | if (!currentSettings.showRequestHeaders) { 297 | delete processedLog.requestHeaders; 298 | } 299 | if (!currentSettings.showResponseHeaders) { 300 | delete processedLog.responseHeaders; 301 | } 302 | } 303 | 304 | return processedLog; 305 | }); 306 | } 307 | 308 | // Helper to calculate size of a log entry 309 | function calculateLogSize(log: any): number { 310 | return JSON.stringify(log).length; 311 | } 312 | 313 | // Helper to truncate logs based on character limit 314 | function truncateLogsToQueryLimit(logs: any[]): any[] { 315 | if (logs.length === 0) return logs; 316 | 317 | // First process logs according to current settings 318 | const processedLogs = processLogsWithSettings(logs); 319 | 320 | let currentSize = 0; 321 | const result = []; 322 | 323 | for (const log of processedLogs) { 324 | const logSize = calculateLogSize(log); 325 | 326 | // Check if adding this log would exceed the limit 327 | if (currentSize + logSize > currentSettings.queryLimit) { 328 | console.log( 329 | `Reached query limit (${currentSize}/${currentSettings.queryLimit}), truncating logs` 330 | ); 331 | break; 332 | } 333 | 334 | // Add log and update size 335 | result.push(log); 336 | currentSize += logSize; 337 | console.log(`Added log of size ${logSize}, total size now: ${currentSize}`); 338 | } 339 | 340 | return result; 341 | } 342 | 343 | // Endpoint for the extension to POST data 344 | app.post("/extension-log", (req, res) => { 345 | console.log("\n=== Received Extension Log ==="); 346 | console.log("Request body:", { 347 | dataType: req.body.data?.type, 348 | timestamp: req.body.data?.timestamp, 349 | hasSettings: !!req.body.settings, 350 | }); 351 | 352 | const { data, settings } = req.body; 353 | 354 | // Update settings if provided 355 | if (settings) { 356 | console.log("Updating settings:", settings); 357 | currentSettings = { 358 | ...currentSettings, 359 | ...settings, 360 | }; 361 | } 362 | 363 | if (!data) { 364 | console.log("Warning: No data received in log request"); 365 | res.status(400).json({ status: "error", message: "No data provided" }); 366 | return; 367 | } 368 | 369 | console.log(`Processing ${data.type} log entry`); 370 | 371 | switch (data.type) { 372 | case "page-navigated": 373 | // Handle page navigation event via HTTP POST 374 | // Note: This is also handled in the WebSocket message handler 375 | // as the extension may send navigation events through either channel 376 | console.log("Received page navigation event with URL:", data.url); 377 | currentUrl = data.url; 378 | 379 | // Also update the tab ID if provided 380 | if (data.tabId) { 381 | console.log("Updating tab ID from page navigation event:", data.tabId); 382 | currentTabId = data.tabId; 383 | } 384 | 385 | console.log("Updated current URL:", currentUrl); 386 | break; 387 | case "console-log": 388 | console.log("Adding console log:", { 389 | level: data.level, 390 | message: 391 | data.message?.substring(0, 100) + 392 | (data.message?.length > 100 ? "..." : ""), 393 | timestamp: data.timestamp, 394 | }); 395 | consoleLogs.push(data); 396 | if (consoleLogs.length > currentSettings.logLimit) { 397 | console.log( 398 | `Console logs exceeded limit (${currentSettings.logLimit}), removing oldest entry` 399 | ); 400 | consoleLogs.shift(); 401 | } 402 | break; 403 | case "console-error": 404 | console.log("Adding console error:", { 405 | level: data.level, 406 | message: 407 | data.message?.substring(0, 100) + 408 | (data.message?.length > 100 ? "..." : ""), 409 | timestamp: data.timestamp, 410 | }); 411 | consoleErrors.push(data); 412 | if (consoleErrors.length > currentSettings.logLimit) { 413 | console.log( 414 | `Console errors exceeded limit (${currentSettings.logLimit}), removing oldest entry` 415 | ); 416 | consoleErrors.shift(); 417 | } 418 | break; 419 | case "network-request": 420 | const logEntry = { 421 | url: data.url, 422 | method: data.method, 423 | status: data.status, 424 | timestamp: data.timestamp, 425 | }; 426 | console.log("Adding network request:", logEntry); 427 | 428 | // Route network requests based on status code 429 | if (data.status >= 400) { 430 | networkErrors.push(data); 431 | if (networkErrors.length > currentSettings.logLimit) { 432 | console.log( 433 | `Network errors exceeded limit (${currentSettings.logLimit}), removing oldest entry` 434 | ); 435 | networkErrors.shift(); 436 | } 437 | } else { 438 | networkSuccess.push(data); 439 | if (networkSuccess.length > currentSettings.logLimit) { 440 | console.log( 441 | `Network success logs exceeded limit (${currentSettings.logLimit}), removing oldest entry` 442 | ); 443 | networkSuccess.shift(); 444 | } 445 | } 446 | break; 447 | case "selected-element": 448 | console.log("Updating selected element:", { 449 | tagName: data.element?.tagName, 450 | id: data.element?.id, 451 | className: data.element?.className, 452 | }); 453 | selectedElement = data.element; 454 | break; 455 | default: 456 | console.log("Unknown log type:", data.type); 457 | } 458 | 459 | console.log("Current log counts:", { 460 | consoleLogs: consoleLogs.length, 461 | consoleErrors: consoleErrors.length, 462 | networkErrors: networkErrors.length, 463 | networkSuccess: networkSuccess.length, 464 | }); 465 | console.log("=== End Extension Log ===\n"); 466 | 467 | res.json({ status: "ok" }); 468 | }); 469 | 470 | // Update GET endpoints to use the new function 471 | app.get("/console-logs", (req, res) => { 472 | const truncatedLogs = truncateLogsToQueryLimit(consoleLogs); 473 | res.json(truncatedLogs); 474 | }); 475 | 476 | app.get("/console-errors", (req, res) => { 477 | const truncatedLogs = truncateLogsToQueryLimit(consoleErrors); 478 | res.json(truncatedLogs); 479 | }); 480 | 481 | app.get("/network-errors", (req, res) => { 482 | const truncatedLogs = truncateLogsToQueryLimit(networkErrors); 483 | res.json(truncatedLogs); 484 | }); 485 | 486 | app.get("/network-success", (req, res) => { 487 | const truncatedLogs = truncateLogsToQueryLimit(networkSuccess); 488 | res.json(truncatedLogs); 489 | }); 490 | 491 | app.get("/all-xhr", (req, res) => { 492 | // Merge and sort network success and error logs by timestamp 493 | const mergedLogs = [...networkSuccess, ...networkErrors].sort( 494 | (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() 495 | ); 496 | const truncatedLogs = truncateLogsToQueryLimit(mergedLogs); 497 | res.json(truncatedLogs); 498 | }); 499 | 500 | // Add new endpoint for selected element 501 | app.post("/selected-element", (req, res) => { 502 | const { data } = req.body; 503 | selectedElement = data; 504 | res.json({ status: "ok" }); 505 | }); 506 | 507 | app.get("/selected-element", (req, res) => { 508 | res.json(selectedElement || { message: "No element selected" }); 509 | }); 510 | 511 | app.get("/.port", (req, res) => { 512 | res.send(PORT.toString()); 513 | }); 514 | 515 | // Add new identity endpoint with a unique signature 516 | app.get("/.identity", (req, res) => { 517 | res.json({ 518 | port: PORT, 519 | name: "browser-tools-server", 520 | version: "1.2.0", 521 | signature: "mcp-browser-connector-24x7", 522 | }); 523 | }); 524 | 525 | // Add function to clear all logs 526 | function clearAllLogs() { 527 | console.log("Wiping all logs..."); 528 | consoleLogs.length = 0; 529 | consoleErrors.length = 0; 530 | networkErrors.length = 0; 531 | networkSuccess.length = 0; 532 | allXhr.length = 0; 533 | selectedElement = null; 534 | console.log("All logs have been wiped"); 535 | } 536 | 537 | // Add endpoint to wipe logs 538 | app.post("/wipelogs", (req, res) => { 539 | clearAllLogs(); 540 | res.json({ status: "ok", message: "All logs cleared successfully" }); 541 | }); 542 | 543 | // Add endpoint for the extension to report the current URL 544 | app.post("/current-url", (req, res) => { 545 | console.log( 546 | "Received current URL update request:", 547 | JSON.stringify(req.body, null, 2) 548 | ); 549 | 550 | if (req.body && req.body.url) { 551 | const oldUrl = currentUrl; 552 | currentUrl = req.body.url; 553 | 554 | // Update the current tab ID if provided 555 | if (req.body.tabId) { 556 | const oldTabId = currentTabId; 557 | currentTabId = req.body.tabId; 558 | console.log(`Updated current tab ID: ${oldTabId} -> ${currentTabId}`); 559 | } 560 | 561 | // Log the source of the update if provided 562 | const source = req.body.source || "unknown"; 563 | const tabId = req.body.tabId || "unknown"; 564 | const timestamp = req.body.timestamp 565 | ? new Date(req.body.timestamp).toISOString() 566 | : "unknown"; 567 | 568 | console.log( 569 | `Updated current URL via dedicated endpoint: ${oldUrl} -> ${currentUrl}` 570 | ); 571 | console.log( 572 | `URL update details: source=${source}, tabId=${tabId}, timestamp=${timestamp}` 573 | ); 574 | 575 | res.json({ 576 | status: "ok", 577 | url: currentUrl, 578 | tabId: currentTabId, 579 | previousUrl: oldUrl, 580 | updated: oldUrl !== currentUrl, 581 | }); 582 | } else { 583 | console.log("No URL provided in current-url request"); 584 | res.status(400).json({ status: "error", message: "No URL provided" }); 585 | } 586 | }); 587 | 588 | // Add endpoint to get the current URL 589 | app.get("/current-url", (req, res) => { 590 | console.log("Current URL requested, returning:", currentUrl); 591 | res.json({ url: currentUrl }); 592 | }); 593 | 594 | interface ScreenshotMessage { 595 | type: "screenshot-data" | "screenshot-error"; 596 | data?: string; 597 | path?: string; 598 | error?: string; 599 | autoPaste?: boolean; 600 | } 601 | 602 | export class BrowserConnector { 603 | private wss: WebSocketServer; 604 | private activeConnection: WebSocket | null = null; 605 | private app: express.Application; 606 | private server: any; 607 | private urlRequestCallbacks: Map<string, (url: string) => void> = new Map(); 608 | 609 | constructor(app: express.Application, server: any) { 610 | this.app = app; 611 | this.server = server; 612 | 613 | // Initialize WebSocket server using the existing HTTP server 614 | this.wss = new WebSocketServer({ 615 | noServer: true, 616 | path: "/extension-ws", 617 | }); 618 | 619 | // Register the capture-screenshot endpoint 620 | this.app.post( 621 | "/capture-screenshot", 622 | async (req: express.Request, res: express.Response) => { 623 | console.log( 624 | "Browser Connector: Received request to /capture-screenshot endpoint" 625 | ); 626 | console.log("Browser Connector: Request body:", req.body); 627 | console.log( 628 | "Browser Connector: Active WebSocket connection:", 629 | !!this.activeConnection 630 | ); 631 | await this.captureScreenshot(req, res); 632 | } 633 | ); 634 | 635 | // Set up accessibility audit endpoint 636 | this.setupAccessibilityAudit(); 637 | 638 | // Set up performance audit endpoint 639 | this.setupPerformanceAudit(); 640 | 641 | // Set up SEO audit endpoint 642 | this.setupSEOAudit(); 643 | 644 | // Set up Best Practices audit endpoint 645 | this.setupBestPracticesAudit(); 646 | 647 | // Handle upgrade requests for WebSocket 648 | this.server.on( 649 | "upgrade", 650 | (request: IncomingMessage, socket: Socket, head: Buffer) => { 651 | if (request.url === "/extension-ws") { 652 | this.wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { 653 | this.wss.emit("connection", ws, request); 654 | }); 655 | } 656 | } 657 | ); 658 | 659 | this.wss.on("connection", (ws: WebSocket) => { 660 | console.log("Chrome extension connected via WebSocket"); 661 | this.activeConnection = ws; 662 | 663 | ws.on("message", (message: string | Buffer | ArrayBuffer | Buffer[]) => { 664 | try { 665 | const data = JSON.parse(message.toString()); 666 | // Log message without the base64 data 667 | console.log("Received WebSocket message:", { 668 | ...data, 669 | data: data.data ? "[base64 data]" : undefined, 670 | }); 671 | 672 | // Handle URL response 673 | if (data.type === "current-url-response" && data.url) { 674 | console.log("Received current URL from browser:", data.url); 675 | currentUrl = data.url; 676 | 677 | // Also update the tab ID if provided 678 | if (data.tabId) { 679 | console.log( 680 | "Updating tab ID from WebSocket message:", 681 | data.tabId 682 | ); 683 | currentTabId = data.tabId; 684 | } 685 | 686 | // Call the callback if exists 687 | if ( 688 | data.requestId && 689 | this.urlRequestCallbacks.has(data.requestId) 690 | ) { 691 | const callback = this.urlRequestCallbacks.get(data.requestId); 692 | if (callback) callback(data.url); 693 | this.urlRequestCallbacks.delete(data.requestId); 694 | } 695 | } 696 | // Handle page navigation event via WebSocket 697 | // Note: This is intentionally duplicated from the HTTP handler in /extension-log 698 | // as the extension may send navigation events through either channel 699 | if (data.type === "page-navigated" && data.url) { 700 | console.log("Page navigated to:", data.url); 701 | currentUrl = data.url; 702 | 703 | // Also update the tab ID if provided 704 | if (data.tabId) { 705 | console.log( 706 | "Updating tab ID from page navigation event:", 707 | data.tabId 708 | ); 709 | currentTabId = data.tabId; 710 | } 711 | } 712 | // Handle screenshot response 713 | if (data.type === "screenshot-data" && data.data) { 714 | console.log("Received screenshot data"); 715 | console.log("Screenshot path from extension:", data.path); 716 | console.log("Auto-paste setting from extension:", data.autoPaste); 717 | // Get the most recent callback since we're not using requestId anymore 718 | const callbacks = Array.from(screenshotCallbacks.values()); 719 | if (callbacks.length > 0) { 720 | const callback = callbacks[0]; 721 | console.log("Found callback, resolving promise"); 722 | // Pass both the data, path and autoPaste to the resolver 723 | callback.resolve({ 724 | data: data.data, 725 | path: data.path, 726 | autoPaste: data.autoPaste, 727 | }); 728 | screenshotCallbacks.clear(); // Clear all callbacks 729 | } else { 730 | console.log("No callbacks found for screenshot"); 731 | } 732 | } 733 | // Handle screenshot error 734 | else if (data.type === "screenshot-error") { 735 | console.log("Received screenshot error:", data.error); 736 | const callbacks = Array.from(screenshotCallbacks.values()); 737 | if (callbacks.length > 0) { 738 | const callback = callbacks[0]; 739 | callback.reject( 740 | new Error(data.error || "Screenshot capture failed") 741 | ); 742 | screenshotCallbacks.clear(); // Clear all callbacks 743 | } 744 | } else { 745 | console.log("Unhandled message type:", data.type); 746 | } 747 | } catch (error) { 748 | console.error("Error processing WebSocket message:", error); 749 | } 750 | }); 751 | 752 | ws.on("close", () => { 753 | console.log("Chrome extension disconnected"); 754 | if (this.activeConnection === ws) { 755 | this.activeConnection = null; 756 | } 757 | }); 758 | }); 759 | 760 | // Add screenshot endpoint 761 | this.app.post( 762 | "/screenshot", 763 | (req: express.Request, res: express.Response): void => { 764 | console.log( 765 | "Browser Connector: Received request to /screenshot endpoint" 766 | ); 767 | console.log("Browser Connector: Request body:", req.body); 768 | try { 769 | console.log("Received screenshot capture request"); 770 | const { data, path: outputPath } = req.body; 771 | 772 | if (!data) { 773 | console.log("Screenshot request missing data"); 774 | res.status(400).json({ error: "Missing screenshot data" }); 775 | return; 776 | } 777 | 778 | // Use provided path or default to downloads folder 779 | const targetPath = outputPath || getDefaultDownloadsFolder(); 780 | console.log(`Using screenshot path: ${targetPath}`); 781 | 782 | // Remove the data:image/png;base64, prefix 783 | const base64Data = data.replace(/^data:image\/png;base64,/, ""); 784 | 785 | // Create the full directory path if it doesn't exist 786 | fs.mkdirSync(targetPath, { recursive: true }); 787 | console.log(`Created/verified directory: ${targetPath}`); 788 | 789 | // Generate a unique filename using timestamp 790 | const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); 791 | const filename = `screenshot-${timestamp}.png`; 792 | const fullPath = path.join(targetPath, filename); 793 | console.log(`Saving screenshot to: ${fullPath}`); 794 | 795 | // Write the file 796 | fs.writeFileSync(fullPath, base64Data, "base64"); 797 | console.log("Screenshot saved successfully"); 798 | 799 | res.json({ 800 | path: fullPath, 801 | filename: filename, 802 | }); 803 | } catch (error: unknown) { 804 | console.error("Error saving screenshot:", error); 805 | if (error instanceof Error) { 806 | res.status(500).json({ error: error.message }); 807 | } else { 808 | res.status(500).json({ error: "An unknown error occurred" }); 809 | } 810 | } 811 | } 812 | ); 813 | } 814 | 815 | private async handleScreenshot(req: express.Request, res: express.Response) { 816 | if (!this.activeConnection) { 817 | return res.status(503).json({ error: "Chrome extension not connected" }); 818 | } 819 | 820 | try { 821 | const result = await new Promise((resolve, reject) => { 822 | // Set up one-time message handler for this screenshot request 823 | const messageHandler = ( 824 | message: string | Buffer | ArrayBuffer | Buffer[] 825 | ) => { 826 | try { 827 | const response: ScreenshotMessage = JSON.parse(message.toString()); 828 | 829 | if (response.type === "screenshot-error") { 830 | reject(new Error(response.error)); 831 | return; 832 | } 833 | 834 | if ( 835 | response.type === "screenshot-data" && 836 | response.data && 837 | response.path 838 | ) { 839 | // Remove the data:image/png;base64, prefix 840 | const base64Data = response.data.replace( 841 | /^data:image\/png;base64,/, 842 | "" 843 | ); 844 | 845 | // Ensure the directory exists 846 | const dir = path.dirname(response.path); 847 | fs.mkdirSync(dir, { recursive: true }); 848 | 849 | // Generate a unique filename using timestamp 850 | const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); 851 | const filename = `screenshot-${timestamp}.png`; 852 | const fullPath = path.join(response.path, filename); 853 | 854 | // Write the file 855 | fs.writeFileSync(fullPath, base64Data, "base64"); 856 | resolve({ 857 | path: fullPath, 858 | filename: filename, 859 | }); 860 | } 861 | } catch (error) { 862 | reject(error); 863 | } finally { 864 | this.activeConnection?.removeListener("message", messageHandler); 865 | } 866 | }; 867 | 868 | // Add temporary message handler 869 | this.activeConnection?.on("message", messageHandler); 870 | 871 | // Request screenshot 872 | this.activeConnection?.send( 873 | JSON.stringify({ type: "take-screenshot" }) 874 | ); 875 | 876 | // Set timeout 877 | setTimeout(() => { 878 | this.activeConnection?.removeListener("message", messageHandler); 879 | reject(new Error("Screenshot timeout")); 880 | }, 30000); // 30 second timeout 881 | }); 882 | 883 | res.json(result); 884 | } catch (error: unknown) { 885 | if (error instanceof Error) { 886 | res.status(500).json({ error: error.message }); 887 | } else { 888 | res.status(500).json({ error: "An unknown error occurred" }); 889 | } 890 | } 891 | } 892 | 893 | // Updated method to get URL for audits with improved connection tracking and waiting 894 | private async getUrlForAudit(): Promise<string | null> { 895 | try { 896 | console.log("getUrlForAudit called"); 897 | 898 | // Use the stored URL if available immediately 899 | if (currentUrl && currentUrl !== "" && currentUrl !== "about:blank") { 900 | console.log(`Using existing URL immediately: ${currentUrl}`); 901 | return currentUrl; 902 | } 903 | 904 | // Wait for a URL to become available (retry loop) 905 | console.log("No valid URL available yet, waiting for navigation..."); 906 | 907 | // Wait up to 10 seconds for a URL to be set (20 attempts x 500ms) 908 | const maxAttempts = 50; 909 | const waitTime = 500; // ms 910 | 911 | for (let attempt = 0; attempt < maxAttempts; attempt++) { 912 | // Check if URL is available now 913 | if (currentUrl && currentUrl !== "" && currentUrl !== "about:blank") { 914 | console.log(`URL became available after waiting: ${currentUrl}`); 915 | return currentUrl; 916 | } 917 | 918 | // Wait before checking again 919 | console.log( 920 | `Waiting for URL (attempt ${attempt + 1}/${maxAttempts})...` 921 | ); 922 | await new Promise((resolve) => setTimeout(resolve, waitTime)); 923 | } 924 | 925 | // If we reach here, no URL became available after waiting 926 | console.log("Timed out waiting for URL, returning null"); 927 | return null; 928 | } catch (error) { 929 | console.error("Error in getUrlForAudit:", error); 930 | return null; // Return null to trigger an error 931 | } 932 | } 933 | 934 | // Public method to check if there's an active connection 935 | public hasActiveConnection(): boolean { 936 | return this.activeConnection !== null; 937 | } 938 | 939 | // Add new endpoint for programmatic screenshot capture 940 | async captureScreenshot(req: express.Request, res: express.Response) { 941 | console.log("Browser Connector: Starting captureScreenshot method"); 942 | console.log("Browser Connector: Request headers:", req.headers); 943 | console.log("Browser Connector: Request method:", req.method); 944 | 945 | if (!this.activeConnection) { 946 | console.log( 947 | "Browser Connector: No active WebSocket connection to Chrome extension" 948 | ); 949 | return res.status(503).json({ error: "Chrome extension not connected" }); 950 | } 951 | 952 | try { 953 | console.log("Browser Connector: Starting screenshot capture..."); 954 | const requestId = Date.now().toString(); 955 | console.log("Browser Connector: Generated requestId:", requestId); 956 | 957 | // Create promise that will resolve when we get the screenshot data 958 | const screenshotPromise = new Promise<{ 959 | data: string; 960 | path?: string; 961 | autoPaste?: boolean; 962 | }>((resolve, reject) => { 963 | console.log( 964 | `Browser Connector: Setting up screenshot callback for requestId: ${requestId}` 965 | ); 966 | // Store callback in map 967 | screenshotCallbacks.set(requestId, { resolve, reject }); 968 | console.log( 969 | "Browser Connector: Current callbacks:", 970 | Array.from(screenshotCallbacks.keys()) 971 | ); 972 | 973 | // Set timeout to clean up if we don't get a response 974 | setTimeout(() => { 975 | if (screenshotCallbacks.has(requestId)) { 976 | console.log( 977 | `Browser Connector: Screenshot capture timed out for requestId: ${requestId}` 978 | ); 979 | screenshotCallbacks.delete(requestId); 980 | reject( 981 | new Error( 982 | "Screenshot capture timed out - no response from Chrome extension" 983 | ) 984 | ); 985 | } 986 | }, 10000); 987 | }); 988 | 989 | // Send screenshot request to extension 990 | const message = JSON.stringify({ 991 | type: "take-screenshot", 992 | requestId: requestId, 993 | }); 994 | console.log( 995 | `Browser Connector: Sending WebSocket message to extension:`, 996 | message 997 | ); 998 | this.activeConnection.send(message); 999 | 1000 | // Wait for screenshot data 1001 | console.log("Browser Connector: Waiting for screenshot data..."); 1002 | const { 1003 | data: base64Data, 1004 | path: customPath, 1005 | autoPaste, 1006 | } = await screenshotPromise; 1007 | console.log("Browser Connector: Received screenshot data, saving..."); 1008 | console.log("Browser Connector: Custom path from extension:", customPath); 1009 | console.log("Browser Connector: Auto-paste setting:", autoPaste); 1010 | 1011 | // Always prioritize the path from the Chrome extension 1012 | let targetPath = customPath; 1013 | 1014 | // If no path provided by extension, fall back to defaults 1015 | if (!targetPath) { 1016 | targetPath = 1017 | currentSettings.screenshotPath || getDefaultDownloadsFolder(); 1018 | } 1019 | 1020 | // Convert the path for the current platform 1021 | targetPath = convertPathForCurrentPlatform(targetPath); 1022 | 1023 | console.log(`Browser Connector: Using path: ${targetPath}`); 1024 | 1025 | if (!base64Data) { 1026 | throw new Error("No screenshot data received from Chrome extension"); 1027 | } 1028 | 1029 | try { 1030 | fs.mkdirSync(targetPath, { recursive: true }); 1031 | console.log(`Browser Connector: Created directory: ${targetPath}`); 1032 | } catch (err) { 1033 | console.error( 1034 | `Browser Connector: Error creating directory: ${targetPath}`, 1035 | err 1036 | ); 1037 | throw new Error( 1038 | `Failed to create screenshot directory: ${ 1039 | err instanceof Error ? err.message : String(err) 1040 | }` 1041 | ); 1042 | } 1043 | 1044 | const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); 1045 | const filename = `screenshot-${timestamp}.png`; 1046 | const fullPath = path.join(targetPath, filename); 1047 | console.log(`Browser Connector: Full screenshot path: ${fullPath}`); 1048 | 1049 | // Remove the data:image/png;base64, prefix if present 1050 | const cleanBase64 = base64Data.replace(/^data:image\/png;base64,/, ""); 1051 | 1052 | // Save the file 1053 | try { 1054 | fs.writeFileSync(fullPath, cleanBase64, "base64"); 1055 | console.log(`Browser Connector: Screenshot saved to: ${fullPath}`); 1056 | } catch (err) { 1057 | console.error( 1058 | `Browser Connector: Error saving screenshot to: ${fullPath}`, 1059 | err 1060 | ); 1061 | throw new Error( 1062 | `Failed to save screenshot: ${ 1063 | err instanceof Error ? err.message : String(err) 1064 | }` 1065 | ); 1066 | } 1067 | 1068 | // Check if running on macOS before executing AppleScript 1069 | if (os.platform() === "darwin" && autoPaste === true) { 1070 | console.log( 1071 | "Browser Connector: Running on macOS with auto-paste enabled, executing AppleScript to paste into Cursor" 1072 | ); 1073 | 1074 | // Create the AppleScript to copy the image to clipboard and paste into Cursor 1075 | // This version is more robust and includes debugging 1076 | const appleScript = ` 1077 | -- Set path to the screenshot 1078 | set imagePath to "${fullPath}" 1079 | 1080 | -- Copy the image to clipboard 1081 | try 1082 | set the clipboard to (read (POSIX file imagePath) as «class PNGf») 1083 | on error errMsg 1084 | log "Error copying image to clipboard: " & errMsg 1085 | return "Failed to copy image to clipboard: " & errMsg 1086 | end try 1087 | 1088 | -- Activate Cursor application 1089 | try 1090 | tell application "Cursor" 1091 | activate 1092 | end tell 1093 | on error errMsg 1094 | log "Error activating Cursor: " & errMsg 1095 | return "Failed to activate Cursor: " & errMsg 1096 | end try 1097 | 1098 | -- Wait for the application to fully activate 1099 | delay 3 1100 | 1101 | -- Try to interact with Cursor 1102 | try 1103 | tell application "System Events" 1104 | tell process "Cursor" 1105 | -- Get the frontmost window 1106 | if (count of windows) is 0 then 1107 | return "No windows found in Cursor" 1108 | end if 1109 | 1110 | set cursorWindow to window 1 1111 | 1112 | -- Try Method 1: Look for elements of class "Text Area" 1113 | set foundElements to {} 1114 | 1115 | -- Try different selectors to find the text input area 1116 | try 1117 | -- Try with class 1118 | set textAreas to UI elements of cursorWindow whose class is "Text Area" 1119 | if (count of textAreas) > 0 then 1120 | set foundElements to textAreas 1121 | end if 1122 | end try 1123 | 1124 | if (count of foundElements) is 0 then 1125 | try 1126 | -- Try with AXTextField role 1127 | set textFields to UI elements of cursorWindow whose role is "AXTextField" 1128 | if (count of textFields) > 0 then 1129 | set foundElements to textFields 1130 | end if 1131 | end try 1132 | end if 1133 | 1134 | if (count of foundElements) is 0 then 1135 | try 1136 | -- Try with AXTextArea role in nested elements 1137 | set allElements to UI elements of cursorWindow 1138 | repeat with anElement in allElements 1139 | try 1140 | set childElements to UI elements of anElement 1141 | repeat with aChild in childElements 1142 | try 1143 | if role of aChild is "AXTextArea" or role of aChild is "AXTextField" then 1144 | set end of foundElements to aChild 1145 | end if 1146 | end try 1147 | end repeat 1148 | end try 1149 | end repeat 1150 | end try 1151 | end if 1152 | 1153 | -- If no elements found with specific attributes, try a broader approach 1154 | if (count of foundElements) is 0 then 1155 | -- Just try to use the Command+V shortcut on the active window 1156 | -- This assumes Cursor already has focus on the right element 1157 | keystroke "v" using command down 1158 | delay 1 1159 | keystroke "here is the screenshot" 1160 | delay 1 1161 | -- Try multiple methods to press Enter 1162 | key code 36 -- Use key code for Return key 1163 | delay 0.5 1164 | keystroke return -- Use keystroke return as alternative 1165 | return "Used fallback method: Command+V on active window" 1166 | else 1167 | -- We found a potential text input element 1168 | set inputElement to item 1 of foundElements 1169 | 1170 | -- Try to focus and paste 1171 | try 1172 | set focused of inputElement to true 1173 | delay 0.5 1174 | 1175 | -- Paste the image 1176 | keystroke "v" using command down 1177 | delay 1 1178 | 1179 | -- Type the text 1180 | keystroke "here is the screenshot" 1181 | delay 1 1182 | -- Try multiple methods to press Enter 1183 | key code 36 -- Use key code for Return key 1184 | delay 0.5 1185 | keystroke return -- Use keystroke return as alternative 1186 | return "Successfully pasted screenshot into Cursor text element" 1187 | on error errMsg 1188 | log "Error interacting with found element: " & errMsg 1189 | -- Fallback to just sending the key commands 1190 | keystroke "v" using command down 1191 | delay 1 1192 | keystroke "here is the screenshot" 1193 | delay 1 1194 | -- Try multiple methods to press Enter 1195 | key code 36 -- Use key code for Return key 1196 | delay 0.5 1197 | keystroke return -- Use keystroke return as alternative 1198 | return "Used fallback after element focus error: " & errMsg 1199 | end try 1200 | end if 1201 | end tell 1202 | end tell 1203 | on error errMsg 1204 | log "Error in System Events block: " & errMsg 1205 | return "Failed in System Events: " & errMsg 1206 | end try 1207 | `; 1208 | 1209 | // Execute the AppleScript 1210 | exec(`osascript -e '${appleScript}'`, (error, stdout, stderr) => { 1211 | if (error) { 1212 | console.error( 1213 | `Browser Connector: Error executing AppleScript: ${error.message}` 1214 | ); 1215 | console.error(`Browser Connector: stderr: ${stderr}`); 1216 | // Don't fail the response; log the error and proceed 1217 | } else { 1218 | console.log(`Browser Connector: AppleScript executed successfully`); 1219 | console.log(`Browser Connector: stdout: ${stdout}`); 1220 | } 1221 | }); 1222 | } else { 1223 | if (os.platform() === "darwin" && !autoPaste) { 1224 | console.log( 1225 | `Browser Connector: Running on macOS but auto-paste is disabled, skipping AppleScript execution` 1226 | ); 1227 | } else { 1228 | console.log( 1229 | `Browser Connector: Not running on macOS, skipping AppleScript execution` 1230 | ); 1231 | } 1232 | } 1233 | 1234 | res.json({ 1235 | path: fullPath, 1236 | filename: filename, 1237 | }); 1238 | } catch (error) { 1239 | const errorMessage = 1240 | error instanceof Error ? error.message : String(error); 1241 | console.error( 1242 | "Browser Connector: Error capturing screenshot:", 1243 | errorMessage 1244 | ); 1245 | res.status(500).json({ 1246 | error: errorMessage, 1247 | }); 1248 | } 1249 | } 1250 | 1251 | // Add shutdown method 1252 | public shutdown() { 1253 | return new Promise<void>((resolve) => { 1254 | console.log("Shutting down WebSocket server..."); 1255 | 1256 | // Send close message to client if connection is active 1257 | if ( 1258 | this.activeConnection && 1259 | this.activeConnection.readyState === WebSocket.OPEN 1260 | ) { 1261 | console.log("Notifying client to close connection..."); 1262 | try { 1263 | this.activeConnection.send( 1264 | JSON.stringify({ type: "server-shutdown" }) 1265 | ); 1266 | } catch (err) { 1267 | console.error("Error sending shutdown message to client:", err); 1268 | } 1269 | } 1270 | 1271 | // Set a timeout to force close after 2 seconds 1272 | const forceCloseTimeout = setTimeout(() => { 1273 | console.log("Force closing connections after timeout..."); 1274 | if (this.activeConnection) { 1275 | this.activeConnection.terminate(); // Force close the connection 1276 | this.activeConnection = null; 1277 | } 1278 | this.wss.close(); 1279 | resolve(); 1280 | }, 2000); 1281 | 1282 | // Close active WebSocket connection if exists 1283 | if (this.activeConnection) { 1284 | this.activeConnection.close(1000, "Server shutting down"); 1285 | this.activeConnection = null; 1286 | } 1287 | 1288 | // Close WebSocket server 1289 | this.wss.close(() => { 1290 | clearTimeout(forceCloseTimeout); 1291 | console.log("WebSocket server closed gracefully"); 1292 | resolve(); 1293 | }); 1294 | }); 1295 | } 1296 | 1297 | // Sets up the accessibility audit endpoint 1298 | private setupAccessibilityAudit() { 1299 | this.setupAuditEndpoint( 1300 | AuditCategory.ACCESSIBILITY, 1301 | "/accessibility-audit", 1302 | runAccessibilityAudit 1303 | ); 1304 | } 1305 | 1306 | // Sets up the performance audit endpoint 1307 | private setupPerformanceAudit() { 1308 | this.setupAuditEndpoint( 1309 | AuditCategory.PERFORMANCE, 1310 | "/performance-audit", 1311 | runPerformanceAudit 1312 | ); 1313 | } 1314 | 1315 | // Set up SEO audit endpoint 1316 | private setupSEOAudit() { 1317 | this.setupAuditEndpoint(AuditCategory.SEO, "/seo-audit", runSEOAudit); 1318 | } 1319 | 1320 | // Add a setup method for Best Practices audit 1321 | private setupBestPracticesAudit() { 1322 | this.setupAuditEndpoint( 1323 | AuditCategory.BEST_PRACTICES, 1324 | "/best-practices-audit", 1325 | runBestPracticesAudit 1326 | ); 1327 | } 1328 | 1329 | /** 1330 | * Generic method to set up an audit endpoint 1331 | * @param auditType The type of audit (accessibility, performance, SEO) 1332 | * @param endpoint The endpoint path 1333 | * @param auditFunction The audit function to call 1334 | */ 1335 | private setupAuditEndpoint( 1336 | auditType: string, 1337 | endpoint: string, 1338 | auditFunction: (url: string) => Promise<LighthouseReport> 1339 | ) { 1340 | // Add server identity validation endpoint 1341 | this.app.get("/.identity", (req, res) => { 1342 | res.json({ 1343 | signature: "mcp-browser-connector-24x7", 1344 | version: "1.2.0", 1345 | }); 1346 | }); 1347 | 1348 | this.app.post(endpoint, async (req: any, res: any) => { 1349 | try { 1350 | console.log(`${auditType} audit request received`); 1351 | 1352 | // Get URL using our helper method 1353 | const url = await this.getUrlForAudit(); 1354 | 1355 | if (!url) { 1356 | console.log(`No URL available for ${auditType} audit`); 1357 | return res.status(400).json({ 1358 | error: `URL is required for ${auditType} audit. Make sure you navigate to a page in the browser first, and the browser-tool extension tab is open.`, 1359 | }); 1360 | } 1361 | 1362 | // If we're using the stored URL (not from request body), log it now 1363 | if (!req.body?.url && url === currentUrl) { 1364 | console.log(`Using stored URL for ${auditType} audit:`, url); 1365 | } 1366 | 1367 | // Check if we're using the default URL 1368 | if (url === "about:blank") { 1369 | console.log(`Cannot run ${auditType} audit on about:blank`); 1370 | return res.status(400).json({ 1371 | error: `Cannot run ${auditType} audit on about:blank`, 1372 | }); 1373 | } 1374 | 1375 | console.log(`Preparing to run ${auditType} audit for: ${url}`); 1376 | 1377 | // Run the audit using the provided function 1378 | try { 1379 | const result = await auditFunction(url); 1380 | 1381 | console.log(`${auditType} audit completed successfully`); 1382 | // Return the results 1383 | res.json(result); 1384 | } catch (auditError) { 1385 | console.error(`${auditType} audit failed:`, auditError); 1386 | const errorMessage = 1387 | auditError instanceof Error 1388 | ? auditError.message 1389 | : String(auditError); 1390 | res.status(500).json({ 1391 | error: `Failed to run ${auditType} audit: ${errorMessage}`, 1392 | }); 1393 | } 1394 | } catch (error) { 1395 | console.error(`Error in ${auditType} audit endpoint:`, error); 1396 | const errorMessage = 1397 | error instanceof Error ? error.message : String(error); 1398 | res.status(500).json({ 1399 | error: `Error in ${auditType} audit endpoint: ${errorMessage}`, 1400 | }); 1401 | } 1402 | }); 1403 | } 1404 | } 1405 | 1406 | // Use an async IIFE to allow for async/await in the initial setup 1407 | (async () => { 1408 | try { 1409 | console.log(`Starting Browser Tools Server...`); 1410 | console.log(`Requested port: ${REQUESTED_PORT}`); 1411 | 1412 | // Find an available port 1413 | try { 1414 | PORT = await getAvailablePort(REQUESTED_PORT); 1415 | 1416 | if (PORT !== REQUESTED_PORT) { 1417 | console.log(`\n====================================`); 1418 | console.log(`NOTICE: Requested port ${REQUESTED_PORT} was in use.`); 1419 | console.log(`Using port ${PORT} instead.`); 1420 | console.log(`====================================\n`); 1421 | } 1422 | } catch (portError) { 1423 | console.error(`Failed to find an available port:`, portError); 1424 | process.exit(1); 1425 | } 1426 | 1427 | // Create the server with the available port 1428 | const server = app.listen(PORT, currentSettings.serverHost, () => { 1429 | console.log(`\n=== Browser Tools Server Started ===`); 1430 | console.log( 1431 | `Aggregator listening on http://${currentSettings.serverHost}:${PORT}` 1432 | ); 1433 | 1434 | if (PORT !== REQUESTED_PORT) { 1435 | console.log( 1436 | `NOTE: Using fallback port ${PORT} instead of requested port ${REQUESTED_PORT}` 1437 | ); 1438 | } 1439 | 1440 | // Log all available network interfaces for easier discovery 1441 | const networkInterfaces = os.networkInterfaces(); 1442 | console.log("\nAvailable on the following network addresses:"); 1443 | 1444 | Object.keys(networkInterfaces).forEach((interfaceName) => { 1445 | const interfaces = networkInterfaces[interfaceName]; 1446 | if (interfaces) { 1447 | interfaces.forEach((iface) => { 1448 | if (!iface.internal && iface.family === "IPv4") { 1449 | console.log(` - http://${iface.address}:${PORT}`); 1450 | } 1451 | }); 1452 | } 1453 | }); 1454 | 1455 | console.log(`\nFor local access use: http://localhost:${PORT}`); 1456 | }); 1457 | 1458 | // Handle server startup errors 1459 | server.on("error", (err: any) => { 1460 | if (err.code === "EADDRINUSE") { 1461 | console.error( 1462 | `ERROR: Port ${PORT} is still in use, despite our checks!` 1463 | ); 1464 | console.error( 1465 | `This might indicate another process started using this port after our check.` 1466 | ); 1467 | } else { 1468 | console.error(`Server error:`, err); 1469 | } 1470 | process.exit(1); 1471 | }); 1472 | 1473 | // Initialize the browser connector with the existing app AND server 1474 | const browserConnector = new BrowserConnector(app, server); 1475 | 1476 | // Handle shutdown gracefully with improved error handling 1477 | process.on("SIGINT", async () => { 1478 | console.log("\nReceived SIGINT signal. Starting graceful shutdown..."); 1479 | 1480 | try { 1481 | // First shutdown WebSocket connections 1482 | await browserConnector.shutdown(); 1483 | 1484 | // Then close the HTTP server 1485 | await new Promise<void>((resolve, reject) => { 1486 | server.close((err) => { 1487 | if (err) { 1488 | console.error("Error closing HTTP server:", err); 1489 | reject(err); 1490 | } else { 1491 | console.log("HTTP server closed successfully"); 1492 | resolve(); 1493 | } 1494 | }); 1495 | }); 1496 | 1497 | // Clear all logs 1498 | clearAllLogs(); 1499 | 1500 | console.log("Shutdown completed successfully"); 1501 | process.exit(0); 1502 | } catch (error) { 1503 | console.error("Error during shutdown:", error); 1504 | // Force exit in case of error 1505 | process.exit(1); 1506 | } 1507 | }); 1508 | 1509 | // Also handle SIGTERM 1510 | process.on("SIGTERM", () => { 1511 | console.log("\nReceived SIGTERM signal"); 1512 | process.emit("SIGINT"); 1513 | }); 1514 | } catch (error) { 1515 | console.error("Failed to start server:", error); 1516 | process.exit(1); 1517 | } 1518 | })().catch((err) => { 1519 | console.error("Unhandled error during server startup:", err); 1520 | process.exit(1); 1521 | }); 1522 | ```