1261 lines
40 KiB
JavaScript

#!/usr/bin/env node
import express from "express";
import cors from "cors";
import bodyParser from "body-parser";
import { tokenizeAndEstimateCost } from "llm-cost";
import { WebSocketServer, WebSocket } from "ws";
import fs from "fs";
import path from "path";
import { IncomingMessage } from "http";
import { Socket } from "net";
import os from "os";
import {
runPerformanceAudit,
runAccessibilityAudit,
runSEOAudit,
AuditCategory,
LighthouseReport,
} from "./lighthouse/index.js";
import * as net from "net";
import { runBestPracticesAudit } from "./lighthouse/best-practices.js";
/**
* Converts a file path to the appropriate format for the current platform
* Handles Windows, WSL, macOS and Linux path formats
*
* @param inputPath - The path to convert
* @returns The converted path appropriate for the current platform
*/
function convertPathForCurrentPlatform(inputPath: string): string {
const platform = os.platform();
// If no path provided, return as is
if (!inputPath) return inputPath;
console.log(`Converting path "${inputPath}" for platform: ${platform}`);
// Windows-specific conversion
if (platform === "win32") {
// Convert forward slashes to backslashes
return inputPath.replace(/\//g, "\\");
}
// Linux/Mac-specific conversion
if (platform === "linux" || platform === "darwin") {
// Check if this is a Windows UNC path (starts with \\)
if (inputPath.startsWith("\\\\") || inputPath.includes("\\")) {
// Check if this is a WSL path (contains wsl.localhost or wsl$)
if (inputPath.includes("wsl.localhost") || inputPath.includes("wsl$")) {
// Extract the path after the distribution name
// Handle both \\wsl.localhost\Ubuntu\path and \\wsl$\Ubuntu\path formats
const parts = inputPath.split("\\").filter((part) => part.length > 0);
console.log("Path parts:", parts);
// Find the index after the distribution name
const distNames = [
"Ubuntu",
"Debian",
"kali",
"openSUSE",
"SLES",
"Fedora",
];
// Find the distribution name in the path
let distIndex = -1;
for (const dist of distNames) {
const index = parts.findIndex(
(part) => part === dist || part.toLowerCase() === dist.toLowerCase()
);
if (index !== -1) {
distIndex = index;
break;
}
}
if (distIndex !== -1 && distIndex + 1 < parts.length) {
// Reconstruct the path as a native Linux path
const linuxPath = "/" + parts.slice(distIndex + 1).join("/");
console.log(
`Converted Windows WSL path "${inputPath}" to Linux path "${linuxPath}"`
);
return linuxPath;
}
// If we couldn't find a distribution name but it's clearly a WSL path,
// try to extract everything after wsl.localhost or wsl$
const wslIndex = parts.findIndex(
(part) =>
part === "wsl.localhost" ||
part === "wsl$" ||
part.toLowerCase() === "wsl.localhost" ||
part.toLowerCase() === "wsl$"
);
if (wslIndex !== -1 && wslIndex + 2 < parts.length) {
// Skip the WSL prefix and distribution name
const linuxPath = "/" + parts.slice(wslIndex + 2).join("/");
console.log(
`Converted Windows WSL path "${inputPath}" to Linux path "${linuxPath}"`
);
return linuxPath;
}
}
// For non-WSL Windows paths, just normalize the slashes
const normalizedPath = inputPath
.replace(/\\\\/g, "/")
.replace(/\\/g, "/");
console.log(
`Converted Windows UNC path "${inputPath}" to "${normalizedPath}"`
);
return normalizedPath;
}
// Handle Windows drive letters (e.g., C:\path\to\file)
if (/^[A-Z]:\\/i.test(inputPath)) {
// Convert Windows drive path to Linux/Mac compatible path
const normalizedPath = inputPath
.replace(/^[A-Z]:\\/i, "/")
.replace(/\\/g, "/");
console.log(
`Converted Windows drive path "${inputPath}" to "${normalizedPath}"`
);
return normalizedPath;
}
}
// Return the original path if no conversion was needed or possible
return inputPath;
}
// Function to get default downloads folder
function getDefaultDownloadsFolder(): string {
const homeDir = os.homedir();
// Downloads folder is typically the same path on Windows, macOS, and Linux
const downloadsPath = path.join(homeDir, "Downloads", "mcp-screenshots");
return downloadsPath;
}
// We store logs in memory
const consoleLogs: any[] = [];
const consoleErrors: any[] = [];
const networkErrors: any[] = [];
const networkSuccess: any[] = [];
const allXhr: any[] = [];
// Store the current URL from the extension
let currentUrl: string = "";
// Store the current tab ID from the extension
let currentTabId: string | number | null = null;
// Add settings state
let currentSettings = {
logLimit: 50,
queryLimit: 30000,
showRequestHeaders: false,
showResponseHeaders: false,
model: "claude-3-sonnet",
stringSizeLimit: 500,
maxLogSize: 20000,
screenshotPath: getDefaultDownloadsFolder(),
// Add server host configuration
serverHost: process.env.SERVER_HOST || "0.0.0.0", // Default to all interfaces
};
// Add new storage for selected element
let selectedElement: any = null;
// Add new state for tracking screenshot requests
interface ScreenshotCallback {
resolve: (value: { data: string; path?: string }) => void;
reject: (reason: Error) => void;
}
const screenshotCallbacks = new Map<string, ScreenshotCallback>();
// Function to get available port starting with the given port
async function getAvailablePort(
startPort: number,
maxAttempts: number = 10
): Promise<number> {
let currentPort = startPort;
let attempts = 0;
while (attempts < maxAttempts) {
try {
// Try to create a server on the current port
// We'll use a raw Node.js net server for just testing port availability
await new Promise<void>((resolve, reject) => {
const testServer = net.createServer();
// Handle errors (e.g., port in use)
testServer.once("error", (err: any) => {
if (err.code === "EADDRINUSE") {
console.log(`Port ${currentPort} is in use, trying next port...`);
currentPort++;
attempts++;
resolve(); // Continue to next iteration
} else {
reject(err); // Different error, propagate it
}
});
// If we can listen, the port is available
testServer.once("listening", () => {
// Make sure to close the server to release the port
testServer.close(() => {
console.log(`Found available port: ${currentPort}`);
resolve();
});
});
// Try to listen on the current port
testServer.listen(currentPort, currentSettings.serverHost);
});
// If we reach here without incrementing the port, it means the port is available
return currentPort;
} catch (error: any) {
console.error(`Error checking port ${currentPort}:`, error);
// For non-EADDRINUSE errors, try the next port
currentPort++;
attempts++;
}
}
// If we've exhausted all attempts, throw an error
throw new Error(
`Could not find an available port after ${maxAttempts} attempts starting from ${startPort}`
);
}
// Start with requested port and find an available one
const REQUESTED_PORT = parseInt(process.env.PORT || "3025", 10);
let PORT = REQUESTED_PORT;
// Create application and initialize middleware
const app = express();
app.use(cors());
// Increase JSON body parser limit to 50MB to handle large screenshots
app.use(bodyParser.json({ limit: "50mb" }));
app.use(bodyParser.urlencoded({ limit: "50mb", extended: true }));
// Helper to recursively truncate strings in any data structure
function truncateStringsInData(data: any, maxLength: number): any {
if (typeof data === "string") {
return data.length > maxLength
? data.substring(0, maxLength) + "... (truncated)"
: data;
}
if (Array.isArray(data)) {
return data.map((item) => truncateStringsInData(item, maxLength));
}
if (typeof data === "object" && data !== null) {
const result: any = {};
for (const [key, value] of Object.entries(data)) {
result[key] = truncateStringsInData(value, maxLength);
}
return result;
}
return data;
}
// Helper to safely parse and process JSON strings
function processJsonString(jsonString: string, maxLength: number): string {
try {
// Try to parse the string as JSON
const parsed = JSON.parse(jsonString);
// Process any strings within the parsed JSON
const processed = truncateStringsInData(parsed, maxLength);
// Stringify the processed data
return JSON.stringify(processed);
} catch (e) {
// If it's not valid JSON, treat it as a regular string
return truncateStringsInData(jsonString, maxLength);
}
}
// Helper to process logs based on settings
function processLogsWithSettings(logs: any[]) {
return logs.map((log) => {
const processedLog = { ...log };
if (log.type === "network-request") {
// Handle headers visibility
if (!currentSettings.showRequestHeaders) {
delete processedLog.requestHeaders;
}
if (!currentSettings.showResponseHeaders) {
delete processedLog.responseHeaders;
}
}
return processedLog;
});
}
// Helper to calculate size of a log entry
function calculateLogSize(log: any): number {
return JSON.stringify(log).length;
}
// Helper to truncate logs based on character limit
function truncateLogsToQueryLimit(logs: any[]): any[] {
if (logs.length === 0) return logs;
// First process logs according to current settings
const processedLogs = processLogsWithSettings(logs);
let currentSize = 0;
const result = [];
for (const log of processedLogs) {
const logSize = calculateLogSize(log);
// Check if adding this log would exceed the limit
if (currentSize + logSize > currentSettings.queryLimit) {
console.log(
`Reached query limit (${currentSize}/${currentSettings.queryLimit}), truncating logs`
);
break;
}
// Add log and update size
result.push(log);
currentSize += logSize;
console.log(`Added log of size ${logSize}, total size now: ${currentSize}`);
}
return result;
}
// Endpoint for the extension to POST data
app.post("/extension-log", (req, res) => {
console.log("\n=== Received Extension Log ===");
console.log("Request body:", {
dataType: req.body.data?.type,
timestamp: req.body.data?.timestamp,
hasSettings: !!req.body.settings,
});
const { data, settings } = req.body;
// Update settings if provided
if (settings) {
console.log("Updating settings:", settings);
currentSettings = {
...currentSettings,
...settings,
};
}
if (!data) {
console.log("Warning: No data received in log request");
res.status(400).json({ status: "error", message: "No data provided" });
return;
}
console.log(`Processing ${data.type} log entry`);
switch (data.type) {
case "page-navigated":
// Handle page navigation event via HTTP POST
// Note: This is also handled in the WebSocket message handler
// as the extension may send navigation events through either channel
console.log("Received page navigation event with URL:", data.url);
currentUrl = data.url;
// Also update the tab ID if provided
if (data.tabId) {
console.log("Updating tab ID from page navigation event:", data.tabId);
currentTabId = data.tabId;
}
console.log("Updated current URL:", currentUrl);
break;
case "console-log":
console.log("Adding console log:", {
level: data.level,
message:
data.message?.substring(0, 100) +
(data.message?.length > 100 ? "..." : ""),
timestamp: data.timestamp,
});
consoleLogs.push(data);
if (consoleLogs.length > currentSettings.logLimit) {
console.log(
`Console logs exceeded limit (${currentSettings.logLimit}), removing oldest entry`
);
consoleLogs.shift();
}
break;
case "console-error":
console.log("Adding console error:", {
level: data.level,
message:
data.message?.substring(0, 100) +
(data.message?.length > 100 ? "..." : ""),
timestamp: data.timestamp,
});
consoleErrors.push(data);
if (consoleErrors.length > currentSettings.logLimit) {
console.log(
`Console errors exceeded limit (${currentSettings.logLimit}), removing oldest entry`
);
consoleErrors.shift();
}
break;
case "network-request":
const logEntry = {
url: data.url,
method: data.method,
status: data.status,
timestamp: data.timestamp,
};
console.log("Adding network request:", logEntry);
// Route network requests based on status code
if (data.status >= 400) {
networkErrors.push(data);
if (networkErrors.length > currentSettings.logLimit) {
console.log(
`Network errors exceeded limit (${currentSettings.logLimit}), removing oldest entry`
);
networkErrors.shift();
}
} else {
networkSuccess.push(data);
if (networkSuccess.length > currentSettings.logLimit) {
console.log(
`Network success logs exceeded limit (${currentSettings.logLimit}), removing oldest entry`
);
networkSuccess.shift();
}
}
break;
case "selected-element":
console.log("Updating selected element:", {
tagName: data.element?.tagName,
id: data.element?.id,
className: data.element?.className,
});
selectedElement = data.element;
break;
default:
console.log("Unknown log type:", data.type);
}
console.log("Current log counts:", {
consoleLogs: consoleLogs.length,
consoleErrors: consoleErrors.length,
networkErrors: networkErrors.length,
networkSuccess: networkSuccess.length,
});
console.log("=== End Extension Log ===\n");
res.json({ status: "ok" });
});
// Update GET endpoints to use the new function
app.get("/console-logs", (req, res) => {
const truncatedLogs = truncateLogsToQueryLimit(consoleLogs);
res.json(truncatedLogs);
});
app.get("/console-errors", (req, res) => {
const truncatedLogs = truncateLogsToQueryLimit(consoleErrors);
res.json(truncatedLogs);
});
app.get("/network-errors", (req, res) => {
const truncatedLogs = truncateLogsToQueryLimit(networkErrors);
res.json(truncatedLogs);
});
app.get("/network-success", (req, res) => {
const truncatedLogs = truncateLogsToQueryLimit(networkSuccess);
res.json(truncatedLogs);
});
app.get("/all-xhr", (req, res) => {
// Merge and sort network success and error logs by timestamp
const mergedLogs = [...networkSuccess, ...networkErrors].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
const truncatedLogs = truncateLogsToQueryLimit(mergedLogs);
res.json(truncatedLogs);
});
// Add new endpoint for selected element
app.post("/selected-element", (req, res) => {
const { data } = req.body;
selectedElement = data;
res.json({ status: "ok" });
});
app.get("/selected-element", (req, res) => {
res.json(selectedElement || { message: "No element selected" });
});
app.get("/.port", (req, res) => {
res.send(PORT.toString());
});
// Add new identity endpoint with a unique signature
app.get("/.identity", (req, res) => {
res.json({
port: PORT,
name: "browser-tools-server",
version: "1.1.0",
signature: "mcp-browser-connector-24x7",
});
});
// Add function to clear all logs
function clearAllLogs() {
console.log("Wiping all logs...");
consoleLogs.length = 0;
consoleErrors.length = 0;
networkErrors.length = 0;
networkSuccess.length = 0;
allXhr.length = 0;
selectedElement = null;
console.log("All logs have been wiped");
}
// Add endpoint to wipe logs
app.post("/wipelogs", (req, res) => {
clearAllLogs();
res.json({ status: "ok", message: "All logs cleared successfully" });
});
// Add endpoint for the extension to report the current URL
app.post("/current-url", (req, res) => {
console.log(
"Received current URL update request:",
JSON.stringify(req.body, null, 2)
);
if (req.body && req.body.url) {
const oldUrl = currentUrl;
currentUrl = req.body.url;
// Update the current tab ID if provided
if (req.body.tabId) {
const oldTabId = currentTabId;
currentTabId = req.body.tabId;
console.log(`Updated current tab ID: ${oldTabId} -> ${currentTabId}`);
}
// Log the source of the update if provided
const source = req.body.source || "unknown";
const tabId = req.body.tabId || "unknown";
const timestamp = req.body.timestamp
? new Date(req.body.timestamp).toISOString()
: "unknown";
console.log(
`Updated current URL via dedicated endpoint: ${oldUrl} -> ${currentUrl}`
);
console.log(
`URL update details: source=${source}, tabId=${tabId}, timestamp=${timestamp}`
);
res.json({
status: "ok",
url: currentUrl,
tabId: currentTabId,
previousUrl: oldUrl,
updated: oldUrl !== currentUrl,
});
} else {
console.log("No URL provided in current-url request");
res.status(400).json({ status: "error", message: "No URL provided" });
}
});
// Add endpoint to get the current URL
app.get("/current-url", (req, res) => {
console.log("Current URL requested, returning:", currentUrl);
res.json({ url: currentUrl });
});
interface ScreenshotMessage {
type: "screenshot-data" | "screenshot-error";
data?: string;
path?: string;
error?: string;
}
export class BrowserConnector {
private wss: WebSocketServer;
private activeConnection: WebSocket | null = null;
private app: express.Application;
private server: any;
private urlRequestCallbacks: Map<string, (url: string) => void> = new Map();
constructor(app: express.Application, server: any) {
this.app = app;
this.server = server;
// Initialize WebSocket server using the existing HTTP server
this.wss = new WebSocketServer({
noServer: true,
path: "/extension-ws",
});
// Register the capture-screenshot endpoint
this.app.post(
"/capture-screenshot",
async (req: express.Request, res: express.Response) => {
console.log(
"Browser Connector: Received request to /capture-screenshot endpoint"
);
console.log("Browser Connector: Request body:", req.body);
console.log(
"Browser Connector: Active WebSocket connection:",
!!this.activeConnection
);
await this.captureScreenshot(req, res);
}
);
// Set up accessibility audit endpoint
this.setupAccessibilityAudit();
// Set up performance audit endpoint
this.setupPerformanceAudit();
// Set up SEO audit endpoint
this.setupSEOAudit();
// Set up Best Practices audit endpoint
this.setupBestPracticesAudit();
// Handle upgrade requests for WebSocket
this.server.on(
"upgrade",
(request: IncomingMessage, socket: Socket, head: Buffer) => {
if (request.url === "/extension-ws") {
this.wss.handleUpgrade(request, socket, head, (ws: WebSocket) => {
this.wss.emit("connection", ws, request);
});
}
}
);
this.wss.on("connection", (ws: WebSocket) => {
console.log("Chrome extension connected via WebSocket");
this.activeConnection = ws;
ws.on("message", (message: string | Buffer | ArrayBuffer | Buffer[]) => {
try {
const data = JSON.parse(message.toString());
// Log message without the base64 data
console.log("Received WebSocket message:", {
...data,
data: data.data ? "[base64 data]" : undefined,
});
// Handle URL response
if (data.type === "current-url-response" && data.url) {
console.log("Received current URL from browser:", data.url);
currentUrl = data.url;
// Also update the tab ID if provided
if (data.tabId) {
console.log(
"Updating tab ID from WebSocket message:",
data.tabId
);
currentTabId = data.tabId;
}
// Call the callback if exists
if (
data.requestId &&
this.urlRequestCallbacks.has(data.requestId)
) {
const callback = this.urlRequestCallbacks.get(data.requestId);
if (callback) callback(data.url);
this.urlRequestCallbacks.delete(data.requestId);
}
}
// Handle page navigation event via WebSocket
// Note: This is intentionally duplicated from the HTTP handler in /extension-log
// as the extension may send navigation events through either channel
if (data.type === "page-navigated" && data.url) {
console.log("Page navigated to:", data.url);
currentUrl = data.url;
// Also update the tab ID if provided
if (data.tabId) {
console.log(
"Updating tab ID from page navigation event:",
data.tabId
);
currentTabId = data.tabId;
}
}
// Handle screenshot response
if (data.type === "screenshot-data" && data.data) {
console.log("Received screenshot data");
console.log("Screenshot path from extension:", data.path);
// Get the most recent callback since we're not using requestId anymore
const callbacks = Array.from(screenshotCallbacks.values());
if (callbacks.length > 0) {
const callback = callbacks[0];
console.log("Found callback, resolving promise");
// Pass both the data and path to the resolver
callback.resolve({ data: data.data, path: data.path });
screenshotCallbacks.clear(); // Clear all callbacks
} else {
console.log("No callbacks found for screenshot");
}
}
// Handle screenshot error
else if (data.type === "screenshot-error") {
console.log("Received screenshot error:", data.error);
const callbacks = Array.from(screenshotCallbacks.values());
if (callbacks.length > 0) {
const callback = callbacks[0];
callback.reject(
new Error(data.error || "Screenshot capture failed")
);
screenshotCallbacks.clear(); // Clear all callbacks
}
} else {
console.log("Unhandled message type:", data.type);
}
} catch (error) {
console.error("Error processing WebSocket message:", error);
}
});
ws.on("close", () => {
console.log("Chrome extension disconnected");
if (this.activeConnection === ws) {
this.activeConnection = null;
}
});
});
// Add screenshot endpoint
this.app.post(
"/screenshot",
(req: express.Request, res: express.Response): void => {
console.log(
"Browser Connector: Received request to /screenshot endpoint"
);
console.log("Browser Connector: Request body:", req.body);
try {
console.log("Received screenshot capture request");
const { data, path: outputPath } = req.body;
if (!data) {
console.log("Screenshot request missing data");
res.status(400).json({ error: "Missing screenshot data" });
return;
}
// Use provided path or default to downloads folder
const targetPath = outputPath || getDefaultDownloadsFolder();
console.log(`Using screenshot path: ${targetPath}`);
// Remove the data:image/png;base64, prefix
const base64Data = data.replace(/^data:image\/png;base64,/, "");
// Create the full directory path if it doesn't exist
fs.mkdirSync(targetPath, { recursive: true });
console.log(`Created/verified directory: ${targetPath}`);
// Generate a unique filename using timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `screenshot-${timestamp}.png`;
const fullPath = path.join(targetPath, filename);
console.log(`Saving screenshot to: ${fullPath}`);
// Write the file
fs.writeFileSync(fullPath, base64Data, "base64");
console.log("Screenshot saved successfully");
res.json({
path: fullPath,
filename: filename,
});
} catch (error: unknown) {
console.error("Error saving screenshot:", error);
if (error instanceof Error) {
res.status(500).json({ error: error.message });
} else {
res.status(500).json({ error: "An unknown error occurred" });
}
}
}
);
}
private async handleScreenshot(req: express.Request, res: express.Response) {
if (!this.activeConnection) {
return res.status(503).json({ error: "Chrome extension not connected" });
}
try {
const result = await new Promise((resolve, reject) => {
// Set up one-time message handler for this screenshot request
const messageHandler = (
message: string | Buffer | ArrayBuffer | Buffer[]
) => {
try {
const response: ScreenshotMessage = JSON.parse(message.toString());
if (response.type === "screenshot-error") {
reject(new Error(response.error));
return;
}
if (
response.type === "screenshot-data" &&
response.data &&
response.path
) {
// Remove the data:image/png;base64, prefix
const base64Data = response.data.replace(
/^data:image\/png;base64,/,
""
);
// Ensure the directory exists
const dir = path.dirname(response.path);
fs.mkdirSync(dir, { recursive: true });
// Generate a unique filename using timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `screenshot-${timestamp}.png`;
const fullPath = path.join(response.path, filename);
// Write the file
fs.writeFileSync(fullPath, base64Data, "base64");
resolve({
path: fullPath,
filename: filename,
});
}
} catch (error) {
reject(error);
} finally {
this.activeConnection?.removeListener("message", messageHandler);
}
};
// Add temporary message handler
this.activeConnection?.on("message", messageHandler);
// Request screenshot
this.activeConnection?.send(
JSON.stringify({ type: "take-screenshot" })
);
// Set timeout
setTimeout(() => {
this.activeConnection?.removeListener("message", messageHandler);
reject(new Error("Screenshot timeout"));
}, 30000); // 30 second timeout
});
res.json(result);
} catch (error: unknown) {
if (error instanceof Error) {
res.status(500).json({ error: error.message });
} else {
res.status(500).json({ error: "An unknown error occurred" });
}
}
}
// Updated method to get URL for audits with improved connection tracking and waiting
private async getUrlForAudit(): Promise<string | null> {
try {
console.log("getUrlForAudit called");
// Use the stored URL if available immediately
if (currentUrl && currentUrl !== "" && currentUrl !== "about:blank") {
console.log(`Using existing URL immediately: ${currentUrl}`);
return currentUrl;
}
// Wait for a URL to become available (retry loop)
console.log("No valid URL available yet, waiting for navigation...");
// Wait up to 10 seconds for a URL to be set (20 attempts x 500ms)
const maxAttempts = 50;
const waitTime = 500; // ms
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Check if URL is available now
if (currentUrl && currentUrl !== "" && currentUrl !== "about:blank") {
console.log(`URL became available after waiting: ${currentUrl}`);
return currentUrl;
}
// Wait before checking again
console.log(
`Waiting for URL (attempt ${attempt + 1}/${maxAttempts})...`
);
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
// If we reach here, no URL became available after waiting
console.log("Timed out waiting for URL, returning null");
return null;
} catch (error) {
console.error("Error in getUrlForAudit:", error);
return null; // Return null to trigger an error
}
}
// Public method to check if there's an active connection
public hasActiveConnection(): boolean {
return this.activeConnection !== null;
}
// Add new endpoint for programmatic screenshot capture
async captureScreenshot(req: express.Request, res: express.Response) {
console.log("Browser Connector: Starting captureScreenshot method");
console.log("Browser Connector: Request headers:", req.headers);
console.log("Browser Connector: Request method:", req.method);
if (!this.activeConnection) {
console.log(
"Browser Connector: No active WebSocket connection to Chrome extension"
);
return res.status(503).json({ error: "Chrome extension not connected" });
}
try {
console.log("Browser Connector: Starting screenshot capture...");
const requestId = Date.now().toString();
console.log("Browser Connector: Generated requestId:", requestId);
// Create promise that will resolve when we get the screenshot data
const screenshotPromise = new Promise<{ data: string; path?: string }>(
(resolve, reject) => {
console.log(
`Browser Connector: Setting up screenshot callback for requestId: ${requestId}`
);
// Store callback in map
screenshotCallbacks.set(requestId, { resolve, reject });
console.log(
"Browser Connector: Current callbacks:",
Array.from(screenshotCallbacks.keys())
);
// Set timeout to clean up if we don't get a response
setTimeout(() => {
if (screenshotCallbacks.has(requestId)) {
console.log(
`Browser Connector: Screenshot capture timed out for requestId: ${requestId}`
);
screenshotCallbacks.delete(requestId);
reject(
new Error(
"Screenshot capture timed out - no response from Chrome extension"
)
);
}
}, 10000);
}
);
// Send screenshot request to extension
const message = JSON.stringify({
type: "take-screenshot",
requestId: requestId,
});
console.log(
`Browser Connector: Sending WebSocket message to extension:`,
message
);
this.activeConnection.send(message);
// Wait for screenshot data
console.log("Browser Connector: Waiting for screenshot data...");
const { data: base64Data, path: customPath } = await screenshotPromise;
console.log("Browser Connector: Received screenshot data, saving...");
console.log("Browser Connector: Custom path from extension:", customPath);
// Always prioritize the path from the Chrome extension
let targetPath = customPath;
// If no path provided by extension, fall back to defaults
if (!targetPath) {
targetPath =
currentSettings.screenshotPath || getDefaultDownloadsFolder();
}
// Convert the path for the current platform
targetPath = convertPathForCurrentPlatform(targetPath);
console.log(`Browser Connector: Using path: ${targetPath}`);
if (!base64Data) {
throw new Error("No screenshot data received from Chrome extension");
}
try {
fs.mkdirSync(targetPath, { recursive: true });
console.log(`Browser Connector: Created directory: ${targetPath}`);
} catch (err) {
console.error(
`Browser Connector: Error creating directory: ${targetPath}`,
err
);
throw new Error(
`Failed to create screenshot directory: ${
err instanceof Error ? err.message : String(err)
}`
);
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `screenshot-${timestamp}.png`;
const fullPath = path.join(targetPath, filename);
console.log(`Browser Connector: Full screenshot path: ${fullPath}`);
// Remove the data:image/png;base64, prefix if present
const cleanBase64 = base64Data.replace(/^data:image\/png;base64,/, "");
// Save the file
try {
fs.writeFileSync(fullPath, cleanBase64, "base64");
console.log(`Browser Connector: Screenshot saved to: ${fullPath}`);
} catch (err) {
console.error(
`Browser Connector: Error saving screenshot to: ${fullPath}`,
err
);
throw new Error(
`Failed to save screenshot: ${
err instanceof Error ? err.message : String(err)
}`
);
}
res.json({
path: fullPath,
filename: filename,
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(
"Browser Connector: Error capturing screenshot:",
errorMessage
);
res.status(500).json({
error: errorMessage,
});
}
}
// Sets up the accessibility audit endpoint
private setupAccessibilityAudit() {
this.setupAuditEndpoint(
AuditCategory.ACCESSIBILITY,
"/accessibility-audit",
runAccessibilityAudit
);
}
// Sets up the performance audit endpoint
private setupPerformanceAudit() {
this.setupAuditEndpoint(
AuditCategory.PERFORMANCE,
"/performance-audit",
runPerformanceAudit
);
}
// Set up SEO audit endpoint
private setupSEOAudit() {
this.setupAuditEndpoint(AuditCategory.SEO, "/seo-audit", runSEOAudit);
}
// Add a setup method for Best Practices audit
private setupBestPracticesAudit() {
this.setupAuditEndpoint(
AuditCategory.BEST_PRACTICES,
"/best-practices-audit",
runBestPracticesAudit
);
}
/**
* Generic method to set up an audit endpoint
* @param auditType The type of audit (accessibility, performance, SEO)
* @param endpoint The endpoint path
* @param auditFunction The audit function to call
*/
private setupAuditEndpoint(
auditType: string,
endpoint: string,
auditFunction: (url: string) => Promise<LighthouseReport>
) {
// Add server identity validation endpoint
this.app.get("/.identity", (req, res) => {
res.json({
signature: "mcp-browser-connector-24x7",
version: "1.1.1",
});
});
this.app.post(endpoint, async (req: any, res: any) => {
try {
console.log(`${auditType} audit request received`);
// Get URL using our helper method
const url = await this.getUrlForAudit();
if (!url) {
console.log(`No URL available for ${auditType} audit`);
return res.status(400).json({
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.`,
});
}
// If we're using the stored URL (not from request body), log it now
if (!req.body?.url && url === currentUrl) {
console.log(`Using stored URL for ${auditType} audit:`, url);
}
// Check if we're using the default URL
if (url === "about:blank") {
console.log(`Cannot run ${auditType} audit on about:blank`);
return res.status(400).json({
error: `Cannot run ${auditType} audit on about:blank`,
});
}
console.log(`Preparing to run ${auditType} audit for: ${url}`);
// Run the audit using the provided function
try {
const result = await auditFunction(url);
console.log(`${auditType} audit completed successfully`);
// Return the results
res.json(result);
} catch (auditError) {
console.error(`${auditType} audit failed:`, auditError);
const errorMessage =
auditError instanceof Error
? auditError.message
: String(auditError);
res.status(500).json({
error: `Failed to run ${auditType} audit: ${errorMessage}`,
});
}
} catch (error) {
console.error(`Error in ${auditType} audit endpoint:`, error);
const errorMessage =
error instanceof Error ? error.message : String(error);
res.status(500).json({
error: `Error in ${auditType} audit endpoint: ${errorMessage}`,
});
}
});
}
}
// Use an async IIFE to allow for async/await in the initial setup
(async () => {
try {
console.log(`Starting Browser Tools Server...`);
console.log(`Requested port: ${REQUESTED_PORT}`);
// Find an available port
try {
PORT = await getAvailablePort(REQUESTED_PORT);
if (PORT !== REQUESTED_PORT) {
console.log(`\n====================================`);
console.log(`NOTICE: Requested port ${REQUESTED_PORT} was in use.`);
console.log(`Using port ${PORT} instead.`);
console.log(`====================================\n`);
}
} catch (portError) {
console.error(`Failed to find an available port:`, portError);
process.exit(1);
}
// Create the server with the available port
const server = app.listen(PORT, currentSettings.serverHost, () => {
console.log(`\n=== Browser Tools Server Started ===`);
console.log(
`Aggregator listening on http://${currentSettings.serverHost}:${PORT}`
);
if (PORT !== REQUESTED_PORT) {
console.log(
`NOTE: Using fallback port ${PORT} instead of requested port ${REQUESTED_PORT}`
);
}
// Log all available network interfaces for easier discovery
const networkInterfaces = os.networkInterfaces();
console.log("\nAvailable on the following network addresses:");
Object.keys(networkInterfaces).forEach((interfaceName) => {
const interfaces = networkInterfaces[interfaceName];
if (interfaces) {
interfaces.forEach((iface) => {
if (!iface.internal && iface.family === "IPv4") {
console.log(` - http://${iface.address}:${PORT}`);
}
});
}
});
console.log(`\nFor local access use: http://localhost:${PORT}`);
});
// Handle server startup errors
server.on("error", (err: any) => {
if (err.code === "EADDRINUSE") {
console.error(
`ERROR: Port ${PORT} is still in use, despite our checks!`
);
console.error(
`This might indicate another process started using this port after our check.`
);
} else {
console.error(`Server error:`, err);
}
process.exit(1);
});
// Initialize the browser connector with the existing app AND server
const browserConnector = new BrowserConnector(app, server);
// Handle shutdown gracefully
process.on("SIGINT", () => {
server.close(() => {
console.log("Server shut down");
process.exit(0);
});
});
} catch (error) {
console.error("Failed to start server:", error);
process.exit(1);
}
})().catch((err) => {
console.error("Unhandled error during server startup:", err);
process.exit(1);
});