wangzengdi e55410bc5c bugfix: Implement Graceful WebSocket Server Shutdown Mechanism
- Added shutdown method to BrowserConnector for controlled WebSocket server closure
- Improved process signal handling for SIGINT and SIGTERM
- Updated Chrome extension to handle server shutdown signal
- Bumped package versions to 1.1.0 for browser-tools-mcp and browser-tools-server
2025-03-11 00:46:44 +08:00

631 lines
18 KiB
JavaScript

// devtools.js
// Store settings with defaults
let settings = {
logLimit: 50,
queryLimit: 30000,
stringSizeLimit: 500,
maxLogSize: 20000,
showRequestHeaders: false,
showResponseHeaders: false,
screenshotPath: "", // Add new setting for screenshot path
};
// Keep track of debugger state
let isDebuggerAttached = false;
let attachDebuggerRetries = 0;
const currentTabId = chrome.devtools.inspectedWindow.tabId;
const MAX_ATTACH_RETRIES = 3;
const ATTACH_RETRY_DELAY = 1000; // 1 second
// Load saved settings on startup
chrome.storage.local.get(["browserConnectorSettings"], (result) => {
if (result.browserConnectorSettings) {
settings = { ...settings, ...result.browserConnectorSettings };
}
});
// Listen for settings updates
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "SETTINGS_UPDATED") {
settings = message.settings;
}
});
// Utility to recursively truncate strings in any data structure
function truncateStringsInData(data, maxLength, depth = 0, path = "") {
// Add depth limit to prevent circular references
if (depth > 100) {
console.warn("Max depth exceeded at path:", path);
return "[MAX_DEPTH_EXCEEDED]";
}
console.log(`Processing at path: ${path}, type:`, typeof data);
if (typeof data === "string") {
if (data.length > maxLength) {
console.log(
`Truncating string at path ${path} from ${data.length} to ${maxLength}`
);
return data.substring(0, maxLength) + "... (truncated)";
}
return data;
}
if (Array.isArray(data)) {
console.log(`Processing array at path ${path} with length:`, data.length);
return data.map((item, index) =>
truncateStringsInData(item, maxLength, depth + 1, `${path}[${index}]`)
);
}
if (typeof data === "object" && data !== null) {
console.log(
`Processing object at path ${path} with keys:`,
Object.keys(data)
);
const result = {};
for (const [key, value] of Object.entries(data)) {
try {
result[key] = truncateStringsInData(
value,
maxLength,
depth + 1,
path ? `${path}.${key}` : key
);
} catch (e) {
console.error(`Error processing key ${key} at path ${path}:`, e);
result[key] = "[ERROR_PROCESSING]";
}
}
return result;
}
return data;
}
// Helper to calculate the size of an object
function calculateObjectSize(obj) {
return JSON.stringify(obj).length;
}
// Helper to process array of objects with size limit
function processArrayWithSizeLimit(array, maxTotalSize, processFunc) {
let currentSize = 0;
const result = [];
for (const item of array) {
// Process the item first
const processedItem = processFunc(item);
const itemSize = calculateObjectSize(processedItem);
// Check if adding this item would exceed the limit
if (currentSize + itemSize > maxTotalSize) {
console.log(
`Reached size limit (${currentSize}/${maxTotalSize}), truncating array`
);
break;
}
// Add item and update size
result.push(processedItem);
currentSize += itemSize;
console.log(
`Added item of size ${itemSize}, total size now: ${currentSize}`
);
}
return result;
}
// Modified processJsonString to handle arrays with size limit
function processJsonString(jsonString, maxLength) {
console.log("Processing string of length:", jsonString?.length);
try {
let parsed;
try {
parsed = JSON.parse(jsonString);
console.log(
"Successfully parsed as JSON, structure:",
JSON.stringify(Object.keys(parsed))
);
} catch (e) {
console.log("Not valid JSON, treating as string");
return truncateStringsInData(jsonString, maxLength, 0, "root");
}
// If it's an array, process with size limit
if (Array.isArray(parsed)) {
console.log("Processing array of objects with size limit");
const processed = processArrayWithSizeLimit(
parsed,
settings.maxLogSize,
(item) => truncateStringsInData(item, maxLength, 0, "root")
);
const result = JSON.stringify(processed);
console.log(
`Processed array: ${parsed.length} -> ${processed.length} items`
);
return result;
}
// Otherwise process as before
const processed = truncateStringsInData(parsed, maxLength, 0, "root");
const result = JSON.stringify(processed);
console.log("Processed JSON string length:", result.length);
return result;
} catch (e) {
console.error("Error in processJsonString:", e);
return jsonString.substring(0, maxLength) + "... (truncated)";
}
}
// Helper to send logs to browser-connector
function sendToBrowserConnector(logData) {
if (!logData) {
console.error("No log data provided to sendToBrowserConnector");
return;
}
console.log("Sending log data to browser connector:", {
type: logData.type,
timestamp: logData.timestamp,
});
// Process any string fields that might contain JSON
const processedData = { ...logData };
if (logData.type === "network-request") {
console.log("Processing network request");
if (processedData.requestBody) {
console.log(
"Request body size before:",
processedData.requestBody.length
);
processedData.requestBody = processJsonString(
processedData.requestBody,
settings.stringSizeLimit
);
console.log("Request body size after:", processedData.requestBody.length);
}
if (processedData.responseBody) {
console.log(
"Response body size before:",
processedData.responseBody.length
);
processedData.responseBody = processJsonString(
processedData.responseBody,
settings.stringSizeLimit
);
console.log(
"Response body size after:",
processedData.responseBody.length
);
}
} else if (
logData.type === "console-log" ||
logData.type === "console-error"
) {
console.log("Processing console message");
if (processedData.message) {
console.log("Message size before:", processedData.message.length);
processedData.message = processJsonString(
processedData.message,
settings.stringSizeLimit
);
console.log("Message size after:", processedData.message.length);
}
}
// Add settings to the request
const payload = {
data: {
...processedData,
timestamp: Date.now(),
},
settings: {
logLimit: settings.logLimit,
queryLimit: settings.queryLimit,
showRequestHeaders: settings.showRequestHeaders,
showResponseHeaders: settings.showResponseHeaders,
},
};
const finalPayloadSize = JSON.stringify(payload).length;
console.log("Final payload size:", finalPayloadSize);
if (finalPayloadSize > 1000000) {
console.warn("Warning: Large payload detected:", finalPayloadSize);
console.warn(
"Payload preview:",
JSON.stringify(payload).substring(0, 1000) + "..."
);
}
fetch("http://127.0.0.1:3025/extension-log", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
console.log("Successfully sent log to browser-connector");
return response.json();
})
.then((data) => {
console.log("Browser connector response:", data);
})
.catch((error) => {
console.error("Failed to send log to browser-connector:", error);
});
}
// Add function to wipe logs
function wipeLogs() {
fetch("http://127.0.0.1:3025/wipelogs", {
method: "POST",
headers: { "Content-Type": "application/json" },
}).catch((error) => {
console.error("Failed to wipe logs:", error);
});
}
// Listen for page refreshes
chrome.devtools.network.onNavigated.addListener(() => {
console.log("Page navigated/refreshed - wiping logs");
wipeLogs();
});
// 1) Listen for network requests
chrome.devtools.network.onRequestFinished.addListener((request) => {
if (request._resourceType === "xhr" || request._resourceType === "fetch") {
request.getContent((responseBody) => {
const entry = {
type: "network-request",
url: request.request.url,
method: request.request.method,
status: request.response.status,
requestHeaders: request.request.headers,
responseHeaders: request.response.headers,
requestBody: request.request.postData?.text ?? "",
responseBody: responseBody ?? "",
};
sendToBrowserConnector(entry);
});
}
});
// Helper function to attach debugger
async function attachDebugger() {
// First check if we're already attached to this tab
chrome.debugger.getTargets((targets) => {
const isAlreadyAttached = targets.some(
(target) => target.tabId === currentTabId && target.attached
);
if (isAlreadyAttached) {
console.log("Found existing debugger attachment, detaching first...");
// Force detach first to ensure clean state
chrome.debugger.detach({ tabId: currentTabId }, () => {
// Ignore any errors during detach
if (chrome.runtime.lastError) {
console.log("Error during forced detach:", chrome.runtime.lastError);
}
// Now proceed with fresh attachment
performAttach();
});
} else {
// No existing attachment, proceed directly
performAttach();
}
});
}
function performAttach() {
console.log("Performing debugger attachment to tab:", currentTabId);
chrome.debugger.attach({ tabId: currentTabId }, "1.3", () => {
if (chrome.runtime.lastError) {
console.error("Failed to attach debugger:", chrome.runtime.lastError);
isDebuggerAttached = false;
return;
}
isDebuggerAttached = true;
console.log("Debugger successfully attached");
// Add the event listener when attaching
chrome.debugger.onEvent.addListener(consoleMessageListener);
chrome.debugger.sendCommand(
{ tabId: currentTabId },
"Runtime.enable",
{},
() => {
if (chrome.runtime.lastError) {
console.error("Failed to enable runtime:", chrome.runtime.lastError);
return;
}
console.log("Runtime API successfully enabled");
}
);
});
}
// Helper function to detach debugger
function detachDebugger() {
// Remove the event listener first
chrome.debugger.onEvent.removeListener(consoleMessageListener);
// Check if debugger is actually attached before trying to detach
chrome.debugger.getTargets((targets) => {
const isStillAttached = targets.some(
(target) => target.tabId === currentTabId && target.attached
);
if (!isStillAttached) {
console.log("Debugger already detached");
isDebuggerAttached = false;
return;
}
chrome.debugger.detach({ tabId: currentTabId }, () => {
if (chrome.runtime.lastError) {
console.warn(
"Warning during debugger detach:",
chrome.runtime.lastError
);
}
isDebuggerAttached = false;
console.log("Debugger detached");
});
});
}
// Move the console message listener outside the panel creation
const consoleMessageListener = (source, method, params) => {
// Only process events for our tab
if (source.tabId !== currentTabId) {
return;
}
if (method === "Runtime.exceptionThrown") {
const entry = {
type: "console-error",
message:
params.exceptionDetails.exception?.description ||
JSON.stringify(params.exceptionDetails),
level: "error",
timestamp: Date.now(),
};
console.log("Sending runtime exception:", entry);
sendToBrowserConnector(entry);
}
if (method === "Runtime.consoleAPICalled") {
// Process all arguments from the console call
let formattedMessage = "";
const args = params.args || [];
// Extract all arguments and combine them
if (args.length > 0) {
// Try to build a meaningful representation of all arguments
try {
formattedMessage = args
.map((arg) => {
// Handle different types of arguments
if (arg.type === "string") {
return arg.value;
} else if (arg.type === "object" && arg.preview) {
// For objects, include their preview or description
return JSON.stringify(arg.preview);
} else if (arg.description) {
// Some objects have descriptions
return arg.description;
} else {
// Fallback for other types
return arg.value || arg.description || JSON.stringify(arg);
}
})
.join(" ");
} catch (e) {
// Fallback if processing fails
console.error("Failed to process console arguments:", e);
formattedMessage =
args[0]?.value || "Unable to process console arguments";
}
}
const entry = {
type: params.type === "error" ? "console-error" : "console-log",
level: params.type,
message: formattedMessage,
timestamp: Date.now(),
};
console.log("Sending console entry:", entry);
sendToBrowserConnector(entry);
}
};
// 2) Use DevTools Protocol to capture console logs
chrome.devtools.panels.create("BrowserToolsMCP", "", "panel.html", (panel) => {
// Initial attach - we'll keep the debugger attached as long as DevTools is open
attachDebugger();
// Handle panel showing
panel.onShown.addListener((panelWindow) => {
if (!isDebuggerAttached) {
attachDebugger();
}
});
});
// Clean up when DevTools window is closed
window.addEventListener("unload", () => {
detachDebugger();
if (ws) {
ws.close();
}
if (wsReconnectTimeout) {
clearTimeout(wsReconnectTimeout);
}
});
// Function to capture and send element data
function captureAndSendElement() {
chrome.devtools.inspectedWindow.eval(
`(function() {
const el = $0; // $0 is the currently selected element in DevTools
if (!el) return null;
const rect = el.getBoundingClientRect();
return {
tagName: el.tagName,
id: el.id,
className: el.className,
textContent: el.textContent?.substring(0, 100),
attributes: Array.from(el.attributes).map(attr => ({
name: attr.name,
value: attr.value
})),
dimensions: {
width: rect.width,
height: rect.height,
top: rect.top,
left: rect.left
},
innerHTML: el.innerHTML.substring(0, 500)
};
})()`,
(result, isException) => {
if (isException || !result) return;
console.log("Element selected:", result);
// Send to browser connector
sendToBrowserConnector({
type: "selected-element",
timestamp: Date.now(),
element: result,
});
}
);
}
// Listen for element selection in the Elements panel
chrome.devtools.panels.elements.onSelectionChanged.addListener(() => {
captureAndSendElement();
});
// WebSocket connection management
let ws = null;
let wsReconnectTimeout = null;
const WS_RECONNECT_DELAY = 5000; // 5 seconds
function setupWebSocket() {
if (ws) {
ws.close();
}
ws = new WebSocket("ws://localhost:3025/extension-ws");
ws.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
console.log("Chrome Extension: Received WebSocket message:", message);
if (message.type === "server-shutdown") {
console.log("Chrome Extension: Received server shutdown signal");
// Clear any reconnection attempts
if (wsReconnectTimeout) {
clearTimeout(wsReconnectTimeout);
wsReconnectTimeout = null;
}
// Close the connection gracefully
ws.close(1000, "Server shutting down");
return;
}
if (message.type === "take-screenshot") {
console.log("Chrome Extension: Taking screenshot...");
// Capture screenshot of the current tab
chrome.tabs.captureVisibleTab(null, { format: "png" }, (dataUrl) => {
if (chrome.runtime.lastError) {
console.error(
"Chrome Extension: Screenshot capture failed:",
chrome.runtime.lastError
);
ws.send(
JSON.stringify({
type: "screenshot-error",
error: chrome.runtime.lastError.message,
requestId: message.requestId,
})
);
return;
}
console.log("Chrome Extension: Screenshot captured successfully");
// Just send the screenshot data, let the server handle paths
const response = {
type: "screenshot-data",
data: dataUrl,
requestId: message.requestId,
// Only include path if it's configured in settings
...(settings.screenshotPath && { path: settings.screenshotPath }),
};
console.log("Chrome Extension: Sending screenshot data response", {
...response,
data: "[base64 data]",
});
ws.send(JSON.stringify(response));
});
}
} catch (error) {
console.error("Chrome Extension: Error processing WebSocket message:", error);
}
};
ws.onopen = () => {
console.log("Chrome Extension: WebSocket connected");
if (wsReconnectTimeout) {
clearTimeout(wsReconnectTimeout);
wsReconnectTimeout = null;
}
};
ws.onclose = (event) => {
console.log(
`Chrome Extension: WebSocket disconnected (${event.code}: ${event.reason})`
);
// Only attempt to reconnect if it wasn't a server shutdown
if (event.reason !== "Server shutting down") {
console.log("Chrome Extension: Attempting to reconnect...");
wsReconnectTimeout = setTimeout(setupWebSocket, WS_RECONNECT_DELAY);
} else {
console.log("Chrome Extension: Server shutdown detected, not reconnecting");
}
};
ws.onerror = (error) => {
console.error("Chrome Extension: WebSocket error:", error);
};
}
// Initialize WebSocket connection when DevTools opens
setupWebSocket();
// Clean up WebSocket when DevTools closes
window.addEventListener("unload", () => {
if (ws) {
ws.close();
}
if (wsReconnectTimeout) {
clearTimeout(wsReconnectTimeout);
}
});