UNPKG

@borgius/brop-mcp

Version:

Browser Remote Operations Protocol with multiplexed CDP relay support

1,326 lines (1,155 loc) โ€ข 38.2 kB
#!/usr/bin/env node /** * BROP Unified Bridge Server (Node.js) * * Unified architecture that combines: * 1. BROP multiplexing for native BROP commands (port 9225) * 2. CDP server for 3rd party tools like Playwright (port 9222) * 3. Chrome extension connection (port 9224) * 4. CDP discovery endpoints * * NO REAL CHROME DEPENDENCY - Everything routes through Chrome Extension APIs * * Key Features: * - Clean protocol separation: BROP vs CDP * - Multiplexed BROP clients for native commands * - CDP compatibility for Playwright/Puppeteer * - Proper session management for Target.* commands * - HTTP discovery endpoints for CDP clients */ import http from "node:http"; import url from "node:url"; import WebSocket, { WebSocketServer } from "ws"; class TableLogger { constructor(options = {}) { this.tsWidth = 19; this.statusWidth = 3; this.typeWidth = 6; this.commandWidth = 20; this.connWidth = 50; this.errorWidth = 20; this.outputStream = options.outputStream || "stdout"; this.mcpMode = options.mcpMode || false; // Pre-compute padding strings for performance this.spacePadding = {}; for (let i = 1; i <= 60; i++) { this.spacePadding[i] = " ".repeat(i); } // Pre-compute separator this.separator = " โ”‚ "; } getTimestamp() { return new Date().toISOString().replace("T", " ").slice(0, 19); } formatField(text, width, align = "left") { const str = String(text || "").slice(0, width); const padding = width - str.length; // Handle emoji special cases if (str === "โœ…" || str === "โŒ" || str === "๐Ÿ”—" || str === "๐Ÿ”Œ") { return str + (this.spacePadding[width - 1] || " ".repeat(width - 1)); } if (padding <= 0) return str; // Use pre-computed padding for performance const spaces = this.spacePadding[padding] || " ".repeat(padding); return align === "right" ? spaces + str : str + spaces; } formatRow(status, type, command, connection, error = "") { const timestamp = this.getTimestamp(); // Use direct concatenation for better performance return this.formatField(timestamp, this.tsWidth) + this.separator + this.formatField(status, this.statusWidth) + this.separator + this.formatField(type, this.typeWidth) + this.separator + this.formatField(command, this.commandWidth) + this.separator + this.formatField(connection, this.connWidth) + this.separator + this.formatField(error, this.errorWidth); } log(message) { if (this.outputStream === "stderr" || this.mcpMode) { console.error(message); } else { console.log(message); } } printHeader() { const header = this.formatRow( "STS", "TYPE", "COMMAND/EVENT", "CONNECTION", "ERROR/DETAILS", ); this.log("โ”€".repeat(header.length)); this.log(header); this.log("โ”€".repeat(header.length)); } logConnect(type, connection) { this.log(this.formatRow("๐Ÿ”—", type, "connect", connection)); } logDisconnect(type, connection) { this.log(this.formatRow("๐Ÿ”Œ", type, "disconnect", connection)); } logSuccess(type, command, connection, details = "") { this.log(this.formatRow("โœ…", type, command, connection, details)); } logError(type, command, connection, error) { this.log(this.formatRow("โŒ", type, command, connection, error)); } logSystem(message) { this.log(`[${this.getTimestamp()}] ${message}`); } } class UnifiedBridgeServer { constructor(options = {}) { this.startTime = Date.now(); // Extension connection (single point of truth) this.extensionClient = null; // BROP client multiplexing this.bropClients = new Set(); this.bropConnections = new Map(); // client -> connection info // CDP client multiplexing with session management this.cdpClients = new Map(); // clientId -> client info this.cdpClientCounter = 0; this.sessionChannels = new Map(); // sessionId -> session info this.targetToSession = new Map(); // targetId -> sessionId this.sessionToTarget = new Map(); // sessionId -> targetId this.targetToClient = new Map(); // targetId -> clientId // Message routing with size limits this.pendingBropRequests = new Map(); // messageId -> bropClient this.pendingCdpRequests = new Map(); // messageId -> requestInfo this.pendingCommandInfo = new Map(); // messageId -> { command, connection } for response logging this.requestTimeouts = new Map(); // messageId -> timeoutId this.maxPendingRequests = 1000; // Prevent unbounded growth this.messageCounter = 0; this.connectionCounter = 0; // Default browser context ID (consistent across session) this.defaultBrowserContextId = this.generateBrowserContextId(); // Server instances this.bropServer = null; this.extensionServer = null; this.cdpServer = null; this.httpServer = null; this.running = false; // Chrome-compatible browser info for CDP discovery this.browserInfo = { Browser: "Chrome/138.0.7204.15", "Protocol-Version": "1.3", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", "V8-Version": "13.8.258.9", "WebKit-Version": "537.36 (@9f1120d029eadbc8ecc5c3d9b298c16d08aabf9f)", webSocketDebuggerUrl: "ws://localhost:9222/devtools/browser/brop-bridge-uuid-12345678", }; // Logs for debugging - circular buffer for efficiency this.maxLogs = 200; // Reduced from 1000 this.logs = new Array(this.maxLogs); this.logIndex = 0; this.logCount = 0; // CDP message logging - circular buffer for efficiency this.maxCdpLogs = 500; // Reduced from 5000 this.cdpLogs = new Array(this.maxCdpLogs); this.cdpLogIndex = 0; this.cdpLogCount = 0; this.cdpLoggingEnabled = false; // Disabled by default // Memory management this.lastCleanup = Date.now(); this.cleanupInterval = null; // Message parsing cache for performance this.messageCache = new Map(); this.maxCacheSize = 100; // Table logger this.logger = new TableLogger({ outputStream: options.logToStderr ? "stderr" : "stdout", mcpMode: options.mcpMode || false, }); // Enable CDP logging if explicitly requested if (options.enableCdpLogging) { this.cdpLoggingEnabled = true; console.log("๐ŸŽญ CDP logging enabled"); } } log(message, ...args) { // Store all logs for debugging endpoint using circular buffer const logEntry = { timestamp: this.logger.getTimestamp(), message: message, args: args, fullMessage: args.length > 0 ? `${message} ${args.join(" ")}` : message, level: "info", }; // Add to circular buffer this.logs[this.logIndex] = logEntry; this.logIndex = (this.logIndex + 1) % this.maxLogs; if (this.logCount < this.maxLogs) { this.logCount++; } // Use system logging for non-structured messages this.logger.logSystem(message); } // Get logs from circular buffer in chronological order getLogs() { if (this.logCount === 0) return []; if (this.logCount < this.maxLogs) { return this.logs.slice(0, this.logCount); } // Return logs in chronological order from circular buffer return [...this.logs.slice(this.logIndex), ...this.logs.slice(0, this.logIndex)]; } getNextMessageId() { this.messageCounter++; return `bridge_${this.messageCounter}`; } getNextConnectionId() { this.connectionCounter++; return `conn_${this.connectionCounter}`; } // Set timeout for pending requests to prevent memory leaks setRequestTimeout(messageId, type = "unknown") { const timeoutId = setTimeout(() => { if (this.pendingBropRequests.has(messageId)) { this.pendingBropRequests.delete(messageId); console.warn(`โฐ BROP request ${messageId} timed out after 2 minutes`); } if (this.pendingCdpRequests.has(messageId)) { this.pendingCdpRequests.delete(messageId); console.warn(`โฐ CDP request ${messageId} timed out after 2 minutes`); } this.pendingCommandInfo.delete(messageId); this.requestTimeouts.delete(messageId); }, 2 * 60 * 1000); // 2 minute timeout this.requestTimeouts.set(messageId, timeoutId); } // Clear request timeout when response is received clearRequestTimeout(messageId) { if (this.requestTimeouts.has(messageId)) { clearTimeout(this.requestTimeouts.get(messageId)); this.requestTimeouts.delete(messageId); } } // Enforce map size limits to prevent unbounded growth enforceMapLimits() { // Clean up oldest BROP requests if over limit if (this.pendingBropRequests.size > this.maxPendingRequests) { const excess = this.pendingBropRequests.size - this.maxPendingRequests; const oldestKeys = Array.from(this.pendingBropRequests.keys()).slice(0, excess + 100); for (const key of oldestKeys) { this.pendingBropRequests.delete(key); this.clearRequestTimeout(key); this.pendingCommandInfo.delete(key); } console.warn(`โš ๏ธ Cleaned ${oldestKeys.length} old BROP requests to prevent memory leak`); } // Clean up oldest CDP requests if over limit if (this.pendingCdpRequests.size > this.maxPendingRequests) { const excess = this.pendingCdpRequests.size - this.maxPendingRequests; const oldestKeys = Array.from(this.pendingCdpRequests.keys()).slice(0, excess + 100); for (const key of oldestKeys) { this.pendingCdpRequests.delete(key); this.clearRequestTimeout(key); } console.warn(`โš ๏ธ Cleaned ${oldestKeys.length} old CDP requests to prevent memory leak`); } } // Helper to format connection display with name getConnectionDisplay(client) { const clientInfo = this.bropConnections.get(client); if (!clientInfo) return "unknown"; return clientInfo.name ? `${clientInfo.id}:${clientInfo.name}` : clientInfo.id; } async startServers() { this.running = true; this.logger.printHeader(); // Start cleanup interval this.cleanupInterval = setInterval(() => { this.performCleanup(); }, 5 * 60 * 1000); // Every 5 minutes try { // Start BROP server (port 9225 - BROP clients) this.bropServer = new WebSocketServer({ port: 9225, host: "127.0.0.1", perMessageDeflate: false, }); this.bropServer.on("connection", (ws, req) => this.handleBropClient(ws, req), ); this.log("๐Ÿ”ง BROP Server started on ws://localhost:9225"); // Start Extension server (port 9224 - extension connects here) this.extensionServer = new WebSocketServer({ port: 9224, host: "127.0.0.1", perMessageDeflate: false, }); this.extensionServer.on("connection", (ws, req) => this.handleExtensionClient(ws, req), ); this.log("๐Ÿ”Œ Extension Server started on ws://localhost:9224"); // Start HTTP server for CDP discovery this.httpServer = http.createServer((req, res) => this.handleHttpRequest(req, res), ); // Start CDP server (port 9222 - CDP clients like Playwright) this.cdpServer = new WebSocketServer({ server: this.httpServer, perMessageDeflate: false, }); this.cdpServer.on("connection", (ws, req) => this.handleCdpClient(ws, req), ); await new Promise((resolve, reject) => { this.httpServer.on("error", reject); this.httpServer.listen(9222, "127.0.0.1", () => { this.log("๐ŸŽญ CDP Server started on ws://localhost:9222"); this.log( "๐ŸŒ HTTP Server started on http://localhost:9222 (CDP discovery)", ); resolve(); }); }); this.log("๐Ÿ“ก Waiting for Chrome extension to connect..."); } catch (error) { console.error("Failed to start servers:", error); throw error; } } handleHttpRequest(req, res) { const pathname = url.parse(req.url).pathname; // Enable CORS res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); res.setHeader("Content-Type", "application/json"); if (req.method === "OPTIONS") { res.writeHead(200); res.end(); return; } if (pathname === "/json/version" || pathname === "/json/version/") { res.writeHead(200); res.end(JSON.stringify(this.browserInfo)); } else if ( pathname === "/json" || pathname === "/json/" || pathname === "/json/list" || pathname === "/json/list/" ) { // Return Chrome-compatible target list const tabs = [ { description: "", devtoolsFrontendUrl: "/devtools/inspector.html?ws=localhost:9222/devtools/browser/brop-bridge-uuid-12345678", id: "brop-bridge-uuid-12345678", title: "Chrome", type: "browser", url: "", webSocketDebuggerUrl: "ws://localhost:9222/devtools/browser/brop-bridge-uuid-12345678", }, ]; res.writeHead(200); res.end(JSON.stringify(tabs)); } else if (pathname === "/logs") { // Return bridge server logs for debugging const urlParams = new URLSearchParams(url.parse(req.url).query); const allLogs = this.getLogs(); const limit = Number.parseInt(urlParams.get("limit")) || allLogs.length; const logsToReturn = allLogs.slice(-Math.min(limit, allLogs.length)); const response = { total: this.logCount, returned: logsToReturn.length, logs: logsToReturn, }; res.writeHead(200); res.end(JSON.stringify(response, null, 2)); } else if (pathname === "/cdp-logs") { // Return CDP traffic logs const urlParams = new URLSearchParams(url.parse(req.url).query); const allCdpLogs = this.getCdpLogs(); const limit = Number.parseInt(urlParams.get("limit")) || allCdpLogs.length; const format = urlParams.get("format") || "json"; const logsToReturn = allCdpLogs.slice(-Math.min(limit, allCdpLogs.length)); if (format === "jsonl") { // Return as JSONL format for CDP traffic analyzer res.setHeader("Content-Type", "application/x-ndjson"); res.writeHead(200); const jsonlContent = logsToReturn .map((log) => JSON.stringify({ direction: log.direction, timestamp: log.timestamp, cdp_data: log.data, }), ) .join("\n"); res.end(jsonlContent); } else { // Return as JSON const response = { total: this.cdpLogCount, returned: logsToReturn.length, logs: logsToReturn, cdpLoggingEnabled: this.cdpLoggingEnabled, }; res.writeHead(200); res.end(JSON.stringify(response, null, 2)); } } else { res.writeHead(404); res.end(JSON.stringify({ error: "Not found" })); } } handleBropClient(ws, req) { const connectionId = this.getNextConnectionId(); const queryParams = url.parse(req.url, true).query; const clientName = queryParams.name || null; const clientInfo = { id: connectionId, name: clientName, connectedAt: Date.now(), remoteAddress: req.socket.remoteAddress || "unknown", }; this.bropConnections.set(ws, clientInfo); const connectionDisplay = clientName ? `${connectionId}:${clientName}` : connectionId; this.logger.logConnect("BROP", connectionDisplay); this.bropClients.add(ws); ws.on("message", (message) => { this.processBropMessage(ws, message.toString()); }); ws.on("close", () => { this.logger.logDisconnect("BROP", connectionDisplay); this.bropClients.delete(ws); this.bropConnections.delete(ws); }); ws.on("error", (error) => { this.logger.logError( "BROP", "connection", connectionDisplay, error.message, ); }); } handleCdpClient(ws, req) { const pathname = url.parse(req.url).pathname; const clientId = `cdp_${++this.cdpClientCounter}`; this.logger.logConnect("CDP", `${clientId}:${pathname}`); // Parse connection type from pathname let isMainBrowser = true; let clientType = "browser"; let targetSessionId = null; if (pathname.startsWith("/devtools/browser/")) { isMainBrowser = true; clientType = "browser"; } else if (pathname.startsWith("/devtools/page/")) { const pageId = pathname.substring("/devtools/page/".length); targetSessionId = pageId; isMainBrowser = false; clientType = "page"; } else if (pathname.startsWith("/session/")) { targetSessionId = pathname.substring("/session/".length); isMainBrowser = false; clientType = "session"; } else { isMainBrowser = this.cdpClients.size === 0; } const clientInfo = { ws: ws, sessionId: targetSessionId || `session_${clientId}`, pathname: pathname, connected: true, targets: new Set(), isMainBrowser: isMainBrowser, isSessionConnection: !isMainBrowser, targetSessionId: targetSessionId, clientType: clientType, created: Date.now(), }; this.cdpClients.set(clientId, clientInfo); ws.on("message", (message) => { this.processCdpMessage(clientId, message.toString()); }); ws.on("close", () => { this.logger.logDisconnect("CDP", `${clientId}:${pathname}`); this.cleanupCdpClient(clientId); }); ws.on("error", (error) => { this.logger.logError( "CDP", "connection", `${clientId}:${pathname}`, error.message, ); this.cleanupCdpClient(clientId); }); } cleanupCdpClient(clientId) { const clientInfo = this.cdpClients.get(clientId); if (clientInfo) { clientInfo.connected = false; // Clean up pending requests for (const [ messageId, requestInfo, ] of this.pendingCdpRequests.entries()) { if (requestInfo.clientId === clientId) { this.pendingCdpRequests.delete(messageId); } } // Clean up session mappings for (const targetId of clientInfo.targets) { this.targetToClient.delete(targetId); const sessionId = this.targetToSession.get(targetId); if (sessionId) { this.sessionChannels.delete(sessionId); this.targetToSession.delete(targetId); this.sessionToTarget.delete(sessionId); } } this.cdpClients.delete(clientId); } } handleExtensionClient(ws, req) { // Check if an extension is already connected if (this.extensionClient && this.extensionClient.readyState === WebSocket.OPEN) { this.logger.logError("EXT", "connect", "extension", "Already connected"); ws.close(1000, "Extension already connected"); return; } this.logger.logConnect("EXT", "extension"); this.extensionClient = ws; ws.send( JSON.stringify({ type: "welcome", message: "BROP Unified Bridge Server - Extension connected", timestamp: Date.now(), }), ); ws.on("message", (message) => { this.processExtensionMessage(message.toString()); }); ws.on("close", () => { this.logger.logDisconnect("EXT", "extension"); this.extensionClient = null; }); ws.on("error", (error) => { this.logger.logError("EXT", "connection", "extension", error.message); }); } processBropMessage(client, message) { try { const data = this.parseMessage(message); const commandType = data.method || data.command?.type; const messageId = data.id || this.getNextMessageId(); const clientInfo = this.bropConnections.get(client); const connectionDisplay = clientInfo?.name ? `${clientInfo.id}:${clientInfo.name}` : clientInfo?.id || "unknown"; if ( !this.extensionClient || this.extensionClient.readyState !== WebSocket.OPEN ) { const errorResponse = { id: messageId, success: false, error: "Chrome extension not connected", }; client.send(JSON.stringify(errorResponse)); this.logger.logError( "BROP", commandType, connectionDisplay, "Extension not connected", ); return; } // Add ID and type for extension processing data.id = messageId; data.type = "brop_command"; // Store client for response routing this.pendingBropRequests.set(messageId, client); // Store command info for response logging this.pendingCommandInfo.set(messageId, { command: commandType, connection: connectionDisplay, }); // Set timeout for this request this.setRequestTimeout(messageId, "BROP"); // Enforce size limits to prevent memory leaks if (this.pendingBropRequests.size % 100 === 0) { this.enforceMapLimits(); } // Forward to extension this.extensionClient.send(JSON.stringify(data)); } catch (error) { this.logger.logError("BROP", "parse", "unknown", error.message); } } processCdpMessage(clientId, message) { try { const data = this.parseMessage(message); const method = data.method; const messageId = data.id; const sessionId = data.sessionId; this.logger.logSuccess("CDP", method, `${clientId}:${messageId}`); // Log CDP request if (this.cdpLoggingEnabled) { this.logCdpMessage({ direction: "client_to_server", timestamp: new Date().toISOString(), clientId: clientId, messageId: messageId, method: method, sessionId: sessionId, data: data, type: "request", }); } const clientInfo = this.cdpClients.get(clientId); if (!clientInfo) { this.logger.logError( "CDP", method, `${clientId}:${messageId}`, "Client not found", ); return; } if ( !this.extensionClient || this.extensionClient.readyState !== WebSocket.OPEN ) { const errorResponse = { id: messageId, error: { code: -32000, message: "Chrome extension not connected" }, }; // Add sessionId if present in the original request if (sessionId) { errorResponse.sessionId = sessionId; } clientInfo.ws.send(JSON.stringify(errorResponse)); this.logger.logError( "CDP", method, `${clientId}:${messageId}`, "Extension not connected", ); return; } // Store request info for response routing this.pendingCdpRequests.set(messageId, { clientId: clientId, originalClient: clientInfo.ws, method: method, sessionId: sessionId, originalParams: data.params, originalCommand: data, }); // Set timeout for this request this.setRequestTimeout(messageId, "CDP"); // Enforce size limits to prevent memory leaks if (this.pendingCdpRequests.size % 100 === 0) { this.enforceMapLimits(); } // Track Target.createTarget commands for session management if (method === "Target.createTarget") { this.pendingTargetCreations = this.pendingTargetCreations || new Map(); this.pendingTargetCreations.set(messageId, clientId); } // Forward CDP command to extension (wrapped as BROP_CDP) const extensionMessage = { type: "BROP_CDP", id: messageId, method: method, params: data.params || {}, sessionId: sessionId, connectionId: clientId, }; this.extensionClient.send(JSON.stringify(extensionMessage)); } catch (error) { this.logger.logError("CDP", "parse", clientId, error.message); } } processExtensionMessage(message) { try { const data = this.parseMessage(message); const messageType = data.type; // Handle ping/pong keepalive if (messageType === "ping") { // Respond with pong if ( this.extensionClient && this.extensionClient.readyState === WebSocket.OPEN ) { this.extensionClient.send( JSON.stringify({ type: "pong", timestamp: Date.now(), originalTimestamp: data.timestamp, }), ); } return; } if (messageType === "response") { const requestId = data.id; // Handle BROP responses if (this.pendingBropRequests.has(requestId)) { const client = this.pendingBropRequests.get(requestId); this.pendingBropRequests.delete(requestId); this.clearRequestTimeout(requestId); if (client.readyState === WebSocket.OPEN) { client.send(JSON.stringify(data)); // Log BROP command result with connection display using stored command info const cmdInfo = this.pendingCommandInfo?.get(requestId); if (cmdInfo) { this.pendingCommandInfo.delete(requestId); if (data.success) { this.logger.logSuccess( "BROP", cmdInfo.command, cmdInfo.connection, ); } else { this.logger.logError( "BROP", cmdInfo.command, cmdInfo.connection, data.error || "Unknown error", ); } } } return; } // Handle CDP responses if (this.pendingCdpRequests.has(requestId)) { const requestInfo = this.pendingCdpRequests.get(requestId); this.pendingCdpRequests.delete(requestId); this.clearRequestTimeout(requestId); // Handle Target.createTarget response - no longer generate attachedToTarget here if (this.pendingTargetCreations?.has(requestId)) { const clientId = this.pendingTargetCreations.get(requestId); this.pendingTargetCreations.delete(requestId); if (data.result?.targetId) { const targetId = data.result.targetId; this.targetToClient.set(targetId, clientId); const clientInfo = this.cdpClients.get(clientId); if (clientInfo) { clientInfo.targets.add(targetId); // Target.attachedToTarget is now generated by the extension } } } // Send CDP response back to client if (requestInfo.originalClient.readyState === WebSocket.OPEN) { const cdpResponse = { id: requestId, result: data.result, error: data.error, }; // Add sessionId if the original request had one if (requestInfo.sessionId) { cdpResponse.sessionId = requestInfo.sessionId; } // Log CDP response if (this.cdpLoggingEnabled) { this.logCdpMessage({ direction: "server_to_client", timestamp: new Date().toISOString(), clientId: requestInfo.clientId, messageId: requestId, method: requestInfo.method, sessionId: requestInfo.sessionId, data: cdpResponse, type: "response", }); } requestInfo.originalClient.send(JSON.stringify(cdpResponse)); } } } else if (messageType === "cdp_event") { // Extension sending a CDP event // Special handling for Target.attachedToTarget to set up session mappings if (data.method === "Target.attachedToTarget") { const { sessionId, targetInfo } = data.params; const targetId = targetInfo.targetId; const connectionId = data.connectionId; // Create session mapping this.targetToSession.set(targetId, sessionId); this.sessionToTarget.set(sessionId, targetId); // Find the client for this connection let clientId = null; for (const [cid, client] of this.cdpClients) { if (cid.includes(connectionId)) { clientId = cid; break; } } if (clientId) { this.sessionChannels.set(sessionId, { clientId: clientId, targetId: targetId, created: Date.now(), }); const clientInfo = this.cdpClients.get(clientId); if (clientInfo) { clientInfo.targets.add(targetId); } } console.log( `๐ŸŽญ Created session mapping: ${sessionId} -> ${targetId}`, ); } // Route all CDP events (including Target.attachedToTarget) this.routeCdpEvent(data); } } catch (error) { this.logger.logError("EXT", "parse", "extension", error.message); } } // No longer needed - Target.attachedToTarget is generated by the extension // sendTargetAttachedEvent(clientId, targetId) { // const sessionId = this.generateSessionId(); // // // Create session mapping // this.targetToSession.set(targetId, sessionId); // this.sessionToTarget.set(sessionId, targetId); // this.sessionChannels.set(sessionId, { // clientId: clientId, // targetId: targetId, // created: Date.now(), // }); // // const clientInfo = this.cdpClients.get(clientId); // if (clientInfo && clientInfo.ws.readyState === WebSocket.OPEN) { // const attachedEvent = { // method: "Target.attachedToTarget", // params: { // sessionId: sessionId, // targetInfo: { // targetId: targetId, // type: "page", // title: "", // url: "about:blank", // attached: true, // canAccessOpener: false, // browserContextId: this.defaultBrowserContextId, // }, // waitingForDebugger: true, // }, // }; // // clientInfo.ws.send(JSON.stringify(attachedEvent)); // this.logger.logSuccess( // "CDP", // "event:Target.attachedToTarget", // `${clientId}:${targetId}`, // ); // } // } routeCdpEvent(eventData) { const method = eventData.method; const params = eventData.params; const tabId = eventData.tabId; const targetId = eventData.targetId; // Log the event for debugging this.logger.logSuccess( "CDP", `event:${method}`, `target_${targetId || "unknown"}`, ); const cdpEventMessage = { method: method, params: params, }; // Look up sessionId based on targetId mapping if (targetId && !method.startsWith("Target.")) { const sessionId = this.targetToSession.get(targetId); if (sessionId) { cdpEventMessage.sessionId = sessionId; this.logger.logSuccess( "CDP", `mapped target ${targetId} to session ${sessionId}`, "", ); } } const messageStr = JSON.stringify(cdpEventMessage); // Route to appropriate CDP client(s) if (method.startsWith("Target.")) { // Browser-level events go to main browser client const mainClient = this.getMainBrowserClient(); if (mainClient) { mainClient.ws.send(messageStr); // Log CDP event if (this.cdpLoggingEnabled) { this.logCdpMessage({ direction: "server_to_client", timestamp: new Date().toISOString(), clientId: "main", messageId: null, method: method, sessionId: cdpEventMessage.sessionId, data: cdpEventMessage, type: "event", }); } } } else if (targetId) { // Target-specific events route to session client const sessionClient = this.getSessionClientForTarget(targetId); if (sessionClient) { sessionClient.ws.send(messageStr); // Log CDP event if (this.cdpLoggingEnabled) { this.logCdpMessage({ direction: "server_to_client", timestamp: new Date().toISOString(), clientId: sessionClient.clientId || "session", messageId: null, method: method, sessionId: cdpEventMessage.sessionId, data: cdpEventMessage, type: "event", }); } } else { // Fallback to main client const mainClient = this.getMainBrowserClient(); if (mainClient) { mainClient.ws.send(messageStr); // Log CDP event fallback if (this.cdpLoggingEnabled) { this.logCdpMessage({ direction: "server_to_client", timestamp: new Date().toISOString(), clientId: "main_fallback", messageId: null, method: method, sessionId: cdpEventMessage.sessionId, data: cdpEventMessage, type: "event", }); } } } } } getMainBrowserClient() { for (const [clientId, clientInfo] of this.cdpClients) { if ( clientInfo.isMainBrowser && clientInfo.connected && clientInfo.ws.readyState === WebSocket.OPEN ) { return clientInfo; } } return null; } getSessionClientForTarget(targetId) { const sessionId = this.targetToSession.get(targetId); if (sessionId) { const sessionInfo = this.sessionChannels.get(sessionId); if (sessionInfo) { const clientInfo = this.cdpClients.get(sessionInfo.clientId); if ( clientInfo?.connected && clientInfo.ws.readyState === WebSocket.OPEN ) { return clientInfo; } } } return null; } generateSessionId() { return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16) .toString(16) .toUpperCase(), ).join(""); } generateBrowserContextId() { // Generate browser context ID in same format as native Chrome (32 char uppercase hex) return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16) .toString(16) .toUpperCase(), ).join(""); } logCdpMessage(logEntry) { // Only log if enabled if (!this.cdpLoggingEnabled) return; // Add CDP message to circular buffer this.cdpLogs[this.cdpLogIndex] = logEntry; this.cdpLogIndex = (this.cdpLogIndex + 1) % this.maxCdpLogs; if (this.cdpLogCount < this.maxCdpLogs) { this.cdpLogCount++; } } // Get CDP logs from circular buffer in chronological order getCdpLogs() { if (this.cdpLogCount === 0) return []; if (this.cdpLogCount < this.maxCdpLogs) { return this.cdpLogs.slice(0, this.cdpLogCount); } // Return logs in chronological order from circular buffer return [...this.cdpLogs.slice(this.cdpLogIndex), ...this.cdpLogs.slice(0, this.cdpLogIndex)]; } // Safe JSON parsing with caching for performance parseMessage(message) { // Check cache first if (this.messageCache.has(message)) { return this.messageCache.get(message); } let parsed; try { parsed = JSON.parse(message); } catch (error) { this.logger.logError("JSON", "parse", "invalid", `Invalid JSON: ${error.message}`); throw new Error(`Invalid JSON message: ${error.message}`); } // Cache the parsed result (with size limit) if (this.messageCache.size >= this.maxCacheSize) { const firstKey = this.messageCache.keys().next().value; this.messageCache.delete(firstKey); } this.messageCache.set(message, parsed); return parsed; } // Perform aggressive memory cleanup performCleanup() { this.lastCleanup = Date.now(); const now = Date.now(); try { // Clean up stale pending requests (older than 5 minutes) const staleThreshold = 5 * 60 * 1000; let cleanedRequests = 0; for (const [messageId, client] of this.pendingBropRequests.entries()) { if (client.readyState !== WebSocket.OPEN) { this.pendingBropRequests.delete(messageId); this.clearRequestTimeout(messageId); cleanedRequests++; } } for (const [messageId, requestInfo] of this.pendingCdpRequests.entries()) { if (!requestInfo.originalClient || requestInfo.originalClient.readyState !== WebSocket.OPEN) { this.pendingCdpRequests.delete(messageId); this.clearRequestTimeout(messageId); cleanedRequests++; } } // Clean up stale command info this.pendingCommandInfo.clear(); // Clean up stale session mappings (reduced from 1 hour to 30 minutes) let cleanedSessions = 0; for (const [sessionId, sessionInfo] of this.sessionChannels.entries()) { if (now - sessionInfo.created > 30 * 60 * 1000) { // 30 minutes old this.sessionChannels.delete(sessionId); this.targetToSession.delete(sessionInfo.targetId); this.sessionToTarget.delete(sessionId); cleanedSessions++; } } // Clean up orphaned target mappings (targets without corresponding sessions) let cleanedTargets = 0; for (const [targetId, sessionId] of this.targetToSession.entries()) { if (!this.sessionChannels.has(sessionId)) { this.targetToSession.delete(targetId); this.sessionToTarget.delete(sessionId); this.targetToClient.delete(targetId); cleanedTargets++; } } // Clean up disconnected CDP clients for (const [clientId, clientInfo] of this.cdpClients.entries()) { if (!clientInfo.connected || clientInfo.ws.readyState !== WebSocket.OPEN) { this.cleanupCdpClient(clientId); } } // Clean up BROP connections for (const client of this.bropConnections.keys()) { if (client.readyState !== WebSocket.OPEN) { this.bropConnections.delete(client); this.bropClients.delete(client); } } // Clean up message cache periodically if (this.messageCache.size > this.maxCacheSize / 2) { const keysToDelete = Array.from(this.messageCache.keys()).slice(0, Math.floor(this.messageCache.size / 2)); keysToDelete.forEach(key => this.messageCache.delete(key)); } const counts = { bropRequests: this.pendingBropRequests.size, cdpRequests: this.pendingCdpRequests.size, sessions: this.sessionChannels.size, cdpClients: this.cdpClients.size, bropClients: this.bropClients.size, logs: this.logCount, cdpLogs: this.cdpLogCount }; if (cleanedSessions > 0 || cleanedTargets > 0) { this.log(`๐Ÿงน Cleanup completed - cleaned ${cleanedSessions} stale sessions, ${cleanedTargets} orphaned targets - remaining: BROP=${counts.bropRequests}, CDP=${counts.cdpRequests}, sessions=${counts.sessions}, clients: CDP=${counts.cdpClients}, BROP=${counts.bropClients}, logs: ${counts.logs}+${counts.cdpLogs}`); } else { this.log(`๐Ÿงน Cleanup completed - pending: BROP=${counts.bropRequests}, CDP=${counts.cdpRequests}, sessions=${counts.sessions}, clients: CDP=${counts.cdpClients}, BROP=${counts.bropClients}, logs: ${counts.logs}+${counts.cdpLogs}`); } } catch (error) { this.log(`Cleanup failed: ${error.message}`); } } async shutdown() { this.log("๐Ÿ›‘ Shutting down unified bridge server..."); this.running = false; // Stop cleanup interval if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } // Clear all pending request timeouts for (const [messageId, timeoutId] of this.requestTimeouts.entries()) { clearTimeout(timeoutId); } this.requestTimeouts.clear(); // Final cleanup this.performCleanup(); if (this.bropServer) this.bropServer.close(); if (this.extensionServer) this.extensionServer.close(); if (this.cdpServer) this.cdpServer.close(); if (this.httpServer) this.httpServer.close(); } } // Main function async function main() { console.log("๐ŸŒ‰ BROP Unified Bridge Server"); console.log("=".repeat(50)); console.log("๐Ÿ”ง BROP Port: 9225 (BROP clients)"); console.log("๐Ÿ”Œ Extension Port: 9224 (extension connects here)"); console.log("๐ŸŽญ CDP Port: 9222 (Playwright/CDP clients)"); console.log("๐ŸŒ NO REAL CHROME DEPENDENCY"); console.log(""); const bridge = new UnifiedBridgeServer(); // Setup signal handlers process.on("SIGINT", () => { console.log("๐Ÿ›‘ Received SIGINT"); bridge.shutdown().then(() => process.exit(0)); }); process.on("SIGTERM", () => { console.log("๐Ÿ›‘ Received SIGTERM"); bridge.shutdown().then(() => process.exit(0)); }); try { await bridge.startServers(); } catch (error) { console.error("๐Ÿ’ฅ Server error:", error); process.exit(1); } } if (import.meta.url === `file://${process.argv[1]}`) { main(); } export { UnifiedBridgeServer };