This commit is contained in:
Tommy D. Rossi
2025-11-16 11:30:28 +01:00
parent a8dc08df1b
commit a2fd82d6d8
19 changed files with 0 additions and 0 deletions
+406
View File
@@ -0,0 +1,406 @@
import { RelayConnection, debugLog } from './relayConnection'
import { create } from 'zustand'
// Relay URL - fixed port for MCP bridge
const RELAY_URL = 'ws://localhost:19988/extension'
type ConnectionState = 'disconnected' | 'reconnecting' | 'connected' | 'error'
interface ExtensionState {
connection: RelayConnection | undefined
connectedTabs: Map<number, string>
connectionState: ConnectionState
currentTabId: number | undefined
errorText: string | undefined
}
const useExtensionStore = create<ExtensionState>(() => ({
connection: undefined,
connectedTabs: new Map(),
connectionState: 'disconnected',
currentTabId: undefined,
errorText: undefined,
}))
async function resetDebugger() {
let targets = await chrome.debugger.getTargets()
targets = targets.filter((x) => x.tabId && x.attached)
console.log(`found ${targets.length} existing debugger targets. detaching them before background script starts`)
console.log(targets)
for (const target of targets) {
await chrome.debugger.detach({ tabId: target.tabId })
}
}
resetDebugger()
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
void chrome.tabs.create({ url: 'welcome.html' })
}
})
const icons = {
connected: {
path: {
'16': '/icons/icon-16.png',
'32': '/icons/icon-32.png',
'48': '/icons/icon-48.png',
'128': '/icons/icon-128.png',
},
title: 'Connected - Click to disconnect',
badgeText: '',
badgeColor: undefined,
},
connecting: {
path: {
'16': '/icons/icon-gray-16.png',
'32': '/icons/icon-gray-32.png',
'48': '/icons/icon-gray-48.png',
'128': '/icons/icon-gray-128.png',
},
title: 'Connecting...',
badgeText: '...',
badgeColor: '#FF9800',
},
disconnected: {
path: {
'16': '/icons/icon-gray-16.png',
'32': '/icons/icon-gray-32.png',
'48': '/icons/icon-gray-48.png',
'128': '/icons/icon-gray-48.png',
},
title: 'Click to attach debugger',
badgeText: '',
badgeColor: undefined,
},
error: {
path: {
'16': '/icons/icon-gray-16.png',
'32': '/icons/icon-gray-32.png',
'48': '/icons/icon-gray-48.png',
'128': '/icons/icon-gray-48.png',
},
title: 'Error',
badgeText: '!',
badgeColor: '#f44336',
},
} as const
useExtensionStore.subscribe(async (state, prevState) => {
console.log(state)
const { connectionState, connectedTabs, errorText } = state
const tabs = await chrome.tabs.query({})
const allTabIds = [undefined, ...tabs.map((tab) => tab.id).filter((id): id is number => id !== undefined)]
for (const tabId of allTabIds) {
const iconConfig = (() => {
if (connectionState === 'error') {
return icons.error
}
if (connectionState === 'reconnecting') {
return icons.connecting
}
if (tabId !== undefined && connectedTabs.has(tabId) && connectionState === 'connected') {
return icons.connected
}
return icons.disconnected
})()
const title = connectionState === 'error' && errorText ? errorText : iconConfig.title
void chrome.action.setIcon({ tabId, path: iconConfig.path })
void chrome.action.setTitle({ tabId, title })
if (iconConfig.badgeColor) void chrome.action.setBadgeBackgroundColor({ tabId, color: iconConfig.badgeColor })
void chrome.action.setBadgeText({ tabId, text: iconConfig.badgeText })
}
})
async function ensureConnection(): Promise<void> {
const { connection } = useExtensionStore.getState()
if (connection) {
debugLog('Connection already exists, reusing')
return
}
debugLog('No existing connection, creating new relay connection')
debugLog('Waiting for server at http://localhost:19988...')
useExtensionStore.setState({ connectionState: 'reconnecting' })
while (true) {
try {
await fetch('http://localhost:19988', { method: 'HEAD' })
debugLog('Server is available')
break
} catch (error: any) {
debugLog('Server not available, retrying in 1 second...')
await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
debugLog('Server is ready, creating WebSocket connection to:', RELAY_URL)
const socket = new WebSocket(RELAY_URL)
debugLog('WebSocket created, initial readyState:', socket.readyState, '(0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)')
await new Promise<void>((resolve, reject) => {
let timeoutFired = false
const timeout = setTimeout(() => {
timeoutFired = true
debugLog('=== WebSocket connection TIMEOUT after 5 seconds ===')
debugLog('Final WebSocket readyState:', socket.readyState)
debugLog('WebSocket URL:', socket.url)
debugLog('Socket protocol:', socket.protocol)
reject(new Error('Connection timeout'))
}, 5000)
socket.onopen = () => {
if (timeoutFired) {
debugLog('WebSocket opened but timeout already fired!')
return
}
debugLog('WebSocket onopen fired! readyState:', socket.readyState)
clearTimeout(timeout)
resolve()
}
socket.onerror = (error) => {
debugLog('WebSocket onerror during connection:', error)
debugLog('Error type:', error.type)
debugLog('Current readyState:', socket.readyState)
if (!timeoutFired) {
clearTimeout(timeout)
reject(new Error('WebSocket connection failed'))
}
}
socket.onclose = (event) => {
debugLog('WebSocket onclose during connection setup:', {
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
readyState: socket.readyState,
})
if (!timeoutFired) {
clearTimeout(timeout)
reject(new Error(`WebSocket closed: ${event.reason || event.code}`))
}
}
debugLog('Event handlers set, waiting for connection...')
})
debugLog('WebSocket connected successfully, creating RelayConnection instance')
const newConnection = new RelayConnection({
ws: socket,
onClose: () => {
debugLog('=== Relay connection onClose callback triggered ===')
const { connectedTabs } = useExtensionStore.getState()
debugLog('Connected tabs before potential reconnection:', Array.from(connectedTabs.keys()))
useExtensionStore.setState({ connection: undefined, connectionState: 'disconnected' })
if (connectedTabs.size > 0) {
debugLog('Tabs still connected, triggering reconnection')
void reconnect()
} else {
debugLog('No tabs to reconnect')
}
},
onTabDetached: (tabId, reason) => {
debugLog('=== Manual tab detachment detected for tab:', tabId, '===')
debugLog('User closed debugger via Chrome automation bar')
useExtensionStore.setState((state) => {
const newTabs = new Map(state.connectedTabs)
newTabs.delete(tabId)
return { connectedTabs: newTabs }
})
if (reason === chrome.debugger.DetachReason.CANCELED_BY_USER) {
// if user cancels debugger. disconnect everything
useExtensionStore.setState({ connectionState: 'disconnected' })
}
debugLog('Removed tab from _connectedTabs map')
},
})
useExtensionStore.setState({ connection: newConnection })
debugLog('Connection established, WebSocket open (caller should set connectionState)')
}
async function connectTab(tabId: number): Promise<void> {
try {
debugLog(`=== Starting connection to tab ${tabId} ===`)
useExtensionStore.setState((state) => {
const newTabs = new Map(state.connectedTabs)
newTabs.set(tabId, '')
return { connectedTabs: newTabs }
})
await ensureConnection()
debugLog('Calling attachTab for tab:', tabId)
const { connection } = useExtensionStore.getState()
if (!connection) return
const targetInfo = await connection.attachTab(tabId)
debugLog('attachTab completed, updating targetId in connectedTabs map')
useExtensionStore.setState((state) => {
const newTabs = new Map(state.connectedTabs)
newTabs.set(tabId, targetInfo?.targetId)
return { connectedTabs: newTabs, connectionState: 'connected' }
})
debugLog(`=== Successfully connected to tab ${tabId} ===`)
} catch (error: any) {
debugLog(`=== Failed to connect to tab ${tabId} ===`)
debugLog('Error details:', error)
debugLog('Error stack:', error.stack)
useExtensionStore.setState((state) => {
const newTabs = new Map(state.connectedTabs)
newTabs.delete(tabId)
return { connectedTabs: newTabs, connectionState: 'error', errorText: `Error: ${error.message}` }
})
}
}
async function disconnectTab(tabId: number): Promise<void> {
debugLog(`=== Disconnecting tab ${tabId} ===`)
const { connectedTabs, connection } = useExtensionStore.getState()
if (!connectedTabs.has(tabId)) {
debugLog('Tab not in connectedTabs map, ignoring disconnect')
return
}
debugLog('Calling detachTab on connection')
connection?.detachTab(tabId)
useExtensionStore.setState((state) => {
const newTabs = new Map(state.connectedTabs)
newTabs.delete(tabId)
return { connectedTabs: newTabs }
})
debugLog('Tab removed from connectedTabs map')
const { connectedTabs: updatedTabs, connection: updatedConnection } = useExtensionStore.getState()
debugLog('Connected tabs remaining:', updatedTabs.size)
if (updatedTabs.size === 0 && updatedConnection) {
debugLog('No tabs remaining, closing relay connection')
updatedConnection.close('All tabs disconnected')
useExtensionStore.setState({ connection: undefined, connectionState: 'disconnected' })
}
}
async function reconnect(): Promise<void> {
debugLog('=== Starting reconnection ===')
const { connectedTabs } = useExtensionStore.getState()
debugLog('Tabs to reconnect:', Array.from(connectedTabs.keys()))
try {
await ensureConnection()
const tabsToReconnect = Array.from(connectedTabs.keys())
debugLog('Re-attaching', tabsToReconnect.length, 'tabs')
for (const tabId of tabsToReconnect) {
const { connectedTabs: currentTabs } = useExtensionStore.getState()
if (!currentTabs.has(tabId)) {
debugLog('Tab', tabId, 'was manually disconnected during reconnection, skipping')
continue
}
try {
debugLog('Checking if tab', tabId, 'still exists')
await chrome.tabs.get(tabId)
debugLog('Re-attaching tab:', tabId)
const { connection } = useExtensionStore.getState()
if (!connection) return
const targetInfo = await connection.attachTab(tabId)
useExtensionStore.setState((state) => {
const newTabs = new Map(state.connectedTabs)
newTabs.set(tabId, targetInfo.targetId)
return { connectedTabs: newTabs }
})
debugLog('Successfully re-attached tab:', tabId)
} catch (error: any) {
debugLog('Failed to re-attach tab:', tabId, error.message)
useExtensionStore.setState((state) => {
const newTabs = new Map(state.connectedTabs)
newTabs.delete(tabId)
return { connectedTabs: newTabs }
})
}
}
const { connectedTabs: finalTabs } = useExtensionStore.getState()
debugLog('=== Reconnection complete ===')
debugLog('Successfully reconnected tabs:', finalTabs.size)
if (finalTabs.size > 0) {
useExtensionStore.setState({ connectionState: 'connected' })
debugLog('Set connectionState to connected')
} else {
debugLog('No tabs successfully reconnected, staying in reconnecting state')
useExtensionStore.setState({ connectionState: 'disconnected' })
}
} catch (error: any) {
debugLog('=== Reconnection failed ===', error)
useExtensionStore.setState({
connectedTabs: new Map(),
connectionState: 'error',
errorText: 'Reconnection failed - Click to retry',
})
}
}
async function onTabRemoved(tabId: number): Promise<void> {
const { connectedTabs } = useExtensionStore.getState()
debugLog('Tab removed event for tab:', tabId, 'is connected:', connectedTabs.has(tabId))
if (!connectedTabs.has(tabId)) return
debugLog(`Connected tab ${tabId} was closed, disconnecting`)
await disconnectTab(tabId)
}
async function onTabActivated(activeInfo: chrome.tabs.TabActiveInfo): Promise<void> {
debugLog('Tab activated:', activeInfo.tabId)
useExtensionStore.setState({ currentTabId: activeInfo.tabId })
}
async function onActionClicked(tab: chrome.tabs.Tab): Promise<void> {
if (!tab.id) {
debugLog('No tab ID available')
return
}
const { connectedTabs, connectionState, connection } = useExtensionStore.getState()
if (connectionState === 'reconnecting' || connectionState === 'error') {
debugLog('User clicked during reconnection/error, canceling and disconnecting all tabs')
const tabsToDisconnect = Array.from(connectedTabs.keys())
for (const tabId of tabsToDisconnect) {
connection?.detachTab(tabId)
}
useExtensionStore.setState({ connectionState: 'disconnected', connectedTabs: new Map(), errorText: undefined })
if (connection) {
connection.close('User cancelled reconnection')
}
return
}
if (connectedTabs.has(tab.id)) {
await disconnectTab(tab.id)
} else {
await connectTab(tab.id)
}
}
debugLog(`Using relay URL: ${RELAY_URL}`)
chrome.tabs.onRemoved.addListener(onTabRemoved)
chrome.tabs.onActivated.addListener(onTabActivated)
chrome.action.onClicked.addListener(onActionClicked)
+384
View File
@@ -0,0 +1,384 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Protocol } from 'playwriter/src/cdp-types';
import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/extension/protocol';
export function debugLog(...args: unknown[]): void {
const enabled = true;
if (enabled) {
// eslint-disable-next-line no-console
console.log('[Extension]', ...args);
}
}
interface AttachedTab {
debuggee: chrome.debugger.Debuggee;
targetId: string;
sessionId: string;
targetInfo: Protocol.Target.TargetInfo;
// Cache execution contexts for this tab. When Playwright reconnects and calls Runtime.enable,
// Chrome's debugger does NOT re-send Runtime.executionContextCreated events for contexts that
// already exist. We must manually replay them so Playwright knows what contexts are available.
// Without this, page.evaluate() hangs because Playwright has no valid execution context IDs.
executionContexts: Map<number, Protocol.Runtime.ExecutionContextCreatedEvent>;
}
export class RelayConnection {
private _attachedTabs: Map<number, AttachedTab> = new Map();
private _nextSessionId: number = 1;
private _ws: WebSocket;
private _closed = false;
private _onCloseCallback?: () => void;
private _onTabDetachedCallback?: (tabId: number, reason: `${chrome.debugger.DetachReason}`) => void
constructor({ ws, onClose, onTabDetached }: {
ws: WebSocket;
onClose?: () => void;
onTabDetached?: (tabId: number, reason: `${chrome.debugger.DetachReason}`) => void;
}) {
this._ws = ws;
this._onCloseCallback = onClose;
this._onTabDetachedCallback = onTabDetached;
this._ws.onmessage = async (event: MessageEvent) => {
let message: ExtensionCommandMessage;
try {
message = JSON.parse(event.data);
} catch (error: any) {
debugLog('Error parsing message:', error);
this._sendMessage({
error: {
code: -32700,
message: `Error parsing message: ${error.message}`,
},
});
return;
}
debugLog('Received message:', message);
const response: ExtensionResponseMessage = {
id: message.id,
};
try {
response.result = await this._handleCommand(message);
} catch (error: any) {
debugLog('Error handling command:', error);
response.error = error.message;
}
debugLog('Sending response:', response);
this._sendMessage(response);
};
this._ws.onclose = (event: CloseEvent) => {
debugLog('WebSocket onclose event:', {
code: event.code,
reason: event.reason,
wasClean: event.wasClean
});
this._onClose();
};
this._ws.onerror = (event: Event) => {
debugLog('WebSocket onerror event:', event);
};
chrome.debugger.onEvent.addListener(this._onDebuggerEvent);
chrome.debugger.onDetach.addListener(this._onDebuggerDetach);
debugLog('RelayConnection created, WebSocket readyState:', this._ws.readyState);
}
async attachTab(tabId: number): Promise<Protocol.Target.TargetInfo> {
const debuggee = { tabId };
debugLog('Attaching debugger to tab:', tabId, 'WebSocket state:', this._ws.readyState);
try {
await chrome.debugger.attach(debuggee, '1.3');
debugLog('Debugger attached successfully to tab:', tabId);
} catch (error: any) {
debugLog('ERROR attaching debugger to tab:', tabId, error);
throw error;
}
debugLog('Sending Target.getTargetInfo command for tab:', tabId);
const result = await chrome.debugger.sendCommand(
debuggee,
'Target.getTargetInfo'
) as Protocol.Target.GetTargetInfoResponse;
debugLog('Received targetInfo for tab:', tabId, result.targetInfo);
const targetInfo = result.targetInfo;
const sessionId = `pw-tab-${this._nextSessionId++}`;
this._attachedTabs.set(tabId, {
debuggee,
targetId: targetInfo.targetId,
sessionId,
targetInfo,
executionContexts: new Map()
});
debugLog('Sending Target.attachedToTarget event, WebSocket state:', this._ws.readyState);
this._sendMessage({
method: 'forwardCDPEvent',
params: {
method: 'Target.attachedToTarget',
params: {
sessionId,
targetInfo: {
...targetInfo,
attached: true
},
waitingForDebugger: false
}
}
});
debugLog('Tab attached successfully:', tabId, 'sessionId:', sessionId, 'targetId:', targetInfo.targetId);
return targetInfo;
}
detachTab(tabId: number): void {
const tab = this._attachedTabs.get(tabId);
if (!tab) {
debugLog('detachTab: tab not found in map:', tabId);
return;
}
debugLog('Detaching tab:', tabId, 'sessionId:', tab.sessionId);
this._sendMessage({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: {
sessionId: tab.sessionId,
targetId: tab.targetId
}
}
});
this._attachedTabs.delete(tabId);
debugLog('Removed tab from _attachedTabs map. Remaining tabs:', this._attachedTabs.size);
chrome.debugger.detach(tab.debuggee)
.then(() => {
debugLog('Successfully detached debugger from tab:', tabId);
})
.catch((err) => {
debugLog('Error detaching debugger from tab:', tabId, err.message);
});
}
close(message: string): void {
debugLog('Closing RelayConnection, reason:', message, 'current state:', this._ws.readyState);
this._ws.close(1000, message);
this._onClose();
}
private _onClose() {
if (this._closed) {
debugLog('_onClose called but already closed');
return;
}
debugLog('Connection closing, attached tabs count:', this._attachedTabs.size);
this._closed = true;
chrome.debugger.onEvent.removeListener(this._onDebuggerEvent);
chrome.debugger.onDetach.removeListener(this._onDebuggerDetach);
const tabIds = Array.from(this._attachedTabs.keys());
debugLog('Detaching all tabs:', tabIds);
for (const [tabId, tab] of this._attachedTabs) {
debugLog('Detaching debugger from tab:', tabId);
chrome.debugger.detach(tab.debuggee)
.then(() => {
debugLog('Successfully detached from tab:', tabId);
})
.catch((err) => {
debugLog('Error detaching from tab:', tabId, err.message);
});
}
this._attachedTabs.clear();
debugLog('All tabs cleared from map. Chrome automation bar should disappear in a few seconds.');
debugLog('Connection closed, calling onClose callback');
this._onCloseCallback?.();
}
private _onDebuggerEvent = (source: chrome.debugger.DebuggerSession, method: string, params: any): void => {
const tab = this._attachedTabs.get(source.tabId!);
if (!tab) return;
// Track execution contexts so we can replay them when Playwright reconnects.
// Chrome's debugger only sends Runtime.executionContextCreated events once per context,
// not on every Runtime.enable call. We cache them here and replay on reconnection.
if (method === 'Runtime.executionContextCreated') {
const contextEvent = params as Protocol.Runtime.ExecutionContextCreatedEvent;
tab.executionContexts.set(contextEvent.context.id, contextEvent);
debugLog('Cached execution context:', contextEvent.context.id, 'for tab:', source.tabId, 'total contexts:', tab.executionContexts.size);
} else if (method === 'Runtime.executionContextDestroyed') {
const destroyedEvent = params as Protocol.Runtime.ExecutionContextDestroyedEvent;
tab.executionContexts.delete(destroyedEvent.executionContextId);
debugLog('Removed execution context:', destroyedEvent.executionContextId, 'from tab:', source.tabId, 'remaining:', tab.executionContexts.size);
} else if (method === 'Runtime.executionContextsCleared') {
tab.executionContexts.clear();
debugLog('Cleared all execution contexts for tab:', source.tabId);
}
debugLog('Forwarding CDP event:', method, 'from tab:', source.tabId);
this._sendMessage({
method: 'forwardCDPEvent',
params: {
sessionId: source.sessionId || tab.sessionId,
method,
params,
},
});
};
private _onDebuggerDetach = (source: chrome.debugger.Debuggee, reason: `${chrome.debugger.DetachReason}`): void => {
const tabId = source.tabId;
debugLog('_onDebuggerDetach called for tab:', tabId, 'reason:', reason, 'isAttached:', tabId ? this._attachedTabs.has(tabId) : false);
if (!tabId || !this._attachedTabs.has(tabId)) {
debugLog('Ignoring debugger detach event for untracked tab:', tabId);
return;
}
debugLog(`Manual debugger detachment detected for tab ${tabId}: ${reason}`);
debugLog('User closed debugger via Chrome automation bar, calling onTabDetached callback');
this._onTabDetachedCallback?.(tabId, reason);
this.detachTab(tabId);
};
private async _handleCommand(message: ExtensionCommandMessage): Promise<any> {
if (message.method === 'attachToTab') {
return {};
}
if (message.method === 'forwardCDPCommand') {
const { sessionId, method, params } = message.params;
if (method === 'Target.createTarget') {
const url = params?.url || 'about:blank';
debugLog('Creating new tab with URL:', url);
const tab = await chrome.tabs.create({ url, active: false });
if (!tab.id) {
throw new Error('Failed to create tab');
}
debugLog('Created tab:', tab.id, 'waiting for it to load...');
// Wait a bit for tab to initialize
await new Promise(resolve => setTimeout(resolve, 100));
// Attach to the new tab
const targetInfo = await this.attachTab(tab.id);
return { targetId: targetInfo.targetId };
}
if (method === 'Target.closeTarget' && params?.targetId) {
debugLog('Closing target:', params.targetId);
for (const [tabId, tab] of this._attachedTabs) {
if (tab.targetId === params.targetId) {
debugLog('Found tab to close:', tabId);
await chrome.tabs.remove(tabId);
return { success: true };
}
}
debugLog('Target not found:', params.targetId);
throw new Error(`Target not found: ${params.targetId}`);
}
let targetTab: AttachedTab | undefined;
for (const [tabId, tab] of this._attachedTabs) {
if (tab.sessionId === sessionId) {
targetTab = tab;
break;
}
}
if (!targetTab) {
if (method === 'Browser.getVersion' || method === 'Target.getTargets') {
targetTab = this._attachedTabs.values().next().value;
}
if (!targetTab) {
throw new Error(`No tab found for sessionId: ${sessionId}`);
}
}
debugLog('CDP command:', method, 'for tab:', targetTab.debuggee.tabId);
const debuggerSession: chrome.debugger.DebuggerSession = {
...targetTab.debuggee,
sessionId: sessionId !== targetTab.sessionId ? sessionId : undefined,
};
const result = await chrome.debugger.sendCommand(
debuggerSession,
method,
params
);
// When Playwright reconnects and calls Runtime.enable, Chrome does NOT automatically
// re-send Runtime.executionContextCreated events for contexts that already exist.
// This causes page.evaluate() to hang because Playwright has no execution context IDs.
// Solution: manually replay all cached contexts so Playwright gets fresh, valid IDs.
if (method === 'Runtime.enable' && targetTab.executionContexts.size > 0) {
debugLog('Runtime.enable called, replaying', targetTab.executionContexts.size, 'cached execution contexts for tab:', targetTab.debuggee.tabId);
for (const contextEvent of targetTab.executionContexts.values()) {
debugLog('Replaying execution context:', contextEvent.context.id);
this._sendMessage({
method: 'forwardCDPEvent',
params: {
sessionId: sessionId,
method: 'Runtime.executionContextCreated',
params: contextEvent,
},
});
}
}
return result;
}
}
private _sendMessage(message: any): void {
if (this._ws.readyState === WebSocket.OPEN) {
try {
this._ws.send(JSON.stringify(message));
debugLog('Message sent successfully, type:', message.method || 'response');
} catch (error: any) {
debugLog('ERROR sending message:', error, 'message type:', message.method || 'response');
}
} else {
debugLog('Cannot send message, WebSocket not open. State:', this._ws.readyState, 'message type:', message.method || 'response');
}
}
}