This is page 3 of 3. Use http://codebase.md/weotzi/browser-tools-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .DS_Store ├── .gitignore ├── browser-tools-mcp │ ├── mcp-server.ts │ ├── package-lock.json │ ├── package.json │ ├── README.md │ └── tsconfig.json ├── browser-tools-server │ ├── browser-connector.ts │ ├── lighthouse │ │ ├── accessibility.ts │ │ ├── best-practices.ts │ │ ├── index.ts │ │ ├── performance.ts │ │ ├── seo.ts │ │ └── types.ts │ ├── package-lock.json │ ├── package.json │ ├── puppeteer-service.ts │ ├── README.md │ └── tsconfig.json ├── chrome-extension │ ├── background.js │ ├── devtools.html │ ├── devtools.js │ ├── manifest.json │ ├── panel.html │ └── panel.js ├── docs │ ├── mcp-docs.md │ └── mcp.md ├── LICENSE └── README.md ``` # Files -------------------------------------------------------------------------------- /browser-tools-server/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 | ```