nicer typescript types

This commit is contained in:
Tommy D. Rossi
2025-11-14 14:35:48 +01:00
parent cef1de124e
commit 50b453a64c
10 changed files with 229 additions and 100 deletions
+4 -1
View File
@@ -23,9 +23,12 @@
},
"devDependencies": {
"@types/chrome": "^0.0.315",
"typescript": "^5.8.2",
"concurrently": "^8.2.2",
"typescript": "^5.8.2",
"vite": "^5.4.21",
"vite-plugin-static-copy": "^3.1.1"
},
"dependencies": {
"playwriter": "workspace:*"
}
}
+9 -20
View File
@@ -14,6 +14,9 @@
* 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) {
@@ -22,20 +25,6 @@ export function debugLog(...args: unknown[]): void {
}
}
type ProtocolCommand = {
id: number;
method: string;
params?: any;
};
type ProtocolResponse = {
id?: number;
method?: string;
params?: any;
result?: any;
error?: string;
};
export class RelayConnection {
private _debuggee: chrome.debugger.Debuggee;
private _ws: WebSocket;
@@ -110,7 +99,7 @@ export class RelayConnection {
}
private async _onMessageAsync(event: MessageEvent): Promise<void> {
let message: ProtocolCommand;
let message: ExtensionCommandMessage;
try {
message = JSON.parse(event.data);
} catch (error: any) {
@@ -121,7 +110,7 @@ export class RelayConnection {
debugLog('Received message:', message);
const response: ProtocolResponse = {
const response: ExtensionResponseMessage = {
id: message.id,
};
try {
@@ -134,12 +123,12 @@ export class RelayConnection {
this._sendMessage(response);
}
private async _handleCommand(message: ProtocolCommand): Promise<any> {
private async _handleCommand(message: ExtensionCommandMessage): Promise<any> {
if (message.method === 'attachToTab') {
await this._tabPromise;
debugLog('Attaching debugger to tab:', this._debuggee);
await chrome.debugger.attach(this._debuggee, '1.3');
const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo');
const result = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo') as Protocol.Target.GetTargetInfoResponse;
return {
targetInfo: result?.targetInfo,
};
@@ -147,13 +136,13 @@ export class RelayConnection {
if (!this._debuggee.tabId)
throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.');
if (message.method === 'forwardCDPCommand') {
const { sessionId, method, params } = message.params;
const forwardParams = message.params as { sessionId?: string; method: string; params?: any };
const { sessionId, method, params } = forwardParams;
debugLog('CDP command:', method, params);
const debuggerSession: chrome.debugger.DebuggerSession = {
...this._debuggee,
sessionId,
};
// Forward CDP command to chrome.debugger
return await chrome.debugger.sendCommand(
debuggerSession,
method,
+8 -1
View File
@@ -1 +1,8 @@
now see where attachToTab is sent and received. see that we basically attach to the tab as soon as the playwright instance connects. I want to change this: instead attach to the tab when we click on the extension icon, meaning inside onClicked. when this happens we should also send also a cdp message from the extension
currently the extension and server code assume there is only one tab attached to debugger only. i want instead to be able to manage multiple tabs. we should update all classes fields that manage tab state to a array {tabId, dsessionId, targetId}[], then we should send relevant commands to the right chrome.debugger debugee tab, finding the right tab for a specific CDP command (based on sessionId or targetId)
---
now see where attachToTab is sent and received. see that we basically attach to the tab as soon as the playwright instance connects. I want to change this: instead attach to the tab when we click on the extension icon, meaning inside onClicked. when this happens we should also send also a cdp message from the extension Target.attachedToTarget after we are connected. so playwright knows of this new page
now see where we already are sending Target.attachedToTarget. this is now sent setAutoAttach, meaning as soon as playwright connects. let's change this. we should not do anything during setAutoAttach, leave it empty for now.
+1
View File
@@ -31,6 +31,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.21.1",
"devtools-protocol": "^0.0.1543509",
"playwright-core": "^1.56.1",
"user-agents": "^1.1.669",
"ws": "^8.18.3",
+124
View File
@@ -0,0 +1,124 @@
import type { Protocol } from 'devtools-protocol';
import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js';
export type CDPCommand<T extends keyof ProtocolMapping.Commands = keyof ProtocolMapping.Commands> = {
id: number;
sessionId?: string;
method: T;
params?: ProtocolMapping.Commands[T]['paramsType'][0];
};
export type CDPResponse<T extends keyof ProtocolMapping.Commands = keyof ProtocolMapping.Commands> = {
id: number;
sessionId?: string;
result?: ProtocolMapping.Commands[T]['returnType'];
error?: { code?: number; message: string };
};
export type CDPEvent<T extends keyof ProtocolMapping.Events = keyof ProtocolMapping.Events> = {
method: T;
sessionId?: string;
params?: ProtocolMapping.Events[T][0];
};
export type CDPMessage = CDPCommand | CDPResponse | CDPEvent;
export { Protocol, ProtocolMapping };
// types tests. to see if types are right with some simple examples
if (false as any) {
const browserVersionCommand = {
id: 1,
method: 'Browser.getVersion',
} satisfies CDPCommand;
const browserVersionResponse = {
id: 1,
result: {
protocolVersion: '1.3',
product: 'Chrome',
revision: '123',
userAgent: 'Mozilla/5.0',
jsVersion: 'V8',
}
} satisfies CDPResponse;
const targetAttachCommand = {
id: 2,
method: 'Target.setAutoAttach',
params: {
autoAttach: true,
waitForDebuggerOnStart: false,
}
} satisfies CDPCommand;
const targetAttachResponse = {
id: 2,
result: undefined,
} satisfies CDPResponse;
const attachedToTargetEvent = {
method: 'Target.attachedToTarget',
params: {
sessionId: 'session-1',
targetInfo: {
targetId: 'target-1',
type: 'page',
title: 'Example',
url: 'https://example.com',
attached: true,
canAccessOpener: false,
},
waitingForDebugger: false,
}
} satisfies CDPEvent;
const consoleMessageEvent = {
method: 'Runtime.consoleAPICalled',
params: {
type: 'log',
args: [],
executionContextId: 1,
timestamp: 123456789,
}
} satisfies CDPEvent;
const pageNavigateCommand = {
id: 3,
method: 'Page.navigate',
params: {
url: 'https://example.com',
}
} satisfies CDPCommand;
const pageNavigateResponse = {
id: 3,
result: {
frameId: 'frame-1',
}
} satisfies CDPResponse;
const networkRequestEvent = {
method: 'Network.requestWillBeSent',
sessionId: 'session-1',
params: {
requestId: 'req-1',
loaderId: 'loader-1',
documentURL: 'https://example.com',
request: {
url: 'https://example.com/api',
method: 'GET',
headers: {},
initialPriority: 'High',
referrerPolicy: 'no-referrer',
},
timestamp: 123456789,
wallTime: 123456789,
initiator: {
type: 'other',
},
redirectHasExtraInfo: false,
type: 'XHR',
}
} satisfies CDPEvent;
}
+32 -49
View File
@@ -34,27 +34,12 @@ import { ManualPromise } from 'playwright-core/lib/utils';
import type { WebSocket, WebSocketServer } from 'playwright-core/lib/utilsBundle';
import type websocket from 'ws';
import type { ExtensionCommand, ExtensionEvents } from './protocol.js';
import type { ExtensionCommandMessage, ExtensionEventMessage, ExtensionMessage } from './protocol.js';
import type { CDPCommand, CDPResponse, CDPEvent, Protocol } from '../cdp-types.js';
const debugLogger = debug('pw:mcp:relay');
type CDPCommand = {
id: number;
sessionId?: string;
method: string;
params?: any;
};
type CDPResponse = {
id?: number;
sessionId?: string;
method?: string;
params?: any;
result?: any;
error?: { code?: number; message: string };
};
export class CDPRelayServer {
private _wsHost: string;
@@ -180,16 +165,14 @@ export class CDPRelayServer {
this._extensionConnectionPromise.resolve();
}
private _handleExtensionMessage<M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) {
switch (method) {
case 'forwardCDPEvent':
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
this._sendToPlaywright({
sessionId,
method: params.method,
params: params.params
});
break;
private _handleExtensionMessage(message: ExtensionEventMessage) {
if (message.method === 'forwardCDPEvent') {
const sessionId = message.params.sessionId || this._connectedTabInfo?.sessionId;
this._sendToPlaywright({
sessionId,
method: message.params.method,
params: message.params.params
} as CDPEvent);
}
}
@@ -215,8 +198,10 @@ export class CDPRelayServer {
return {
protocolVersion: '1.3',
product: 'Chrome/Extension-Bridge',
revision: '1.0.0',
userAgent: 'CDP-Bridge-Server/1.0.0',
};
jsVersion: 'V8',
} satisfies Protocol.Browser.GetVersionResponse;
}
case 'Browser.setDownloadBehavior': {
return { };
@@ -228,7 +213,7 @@ export class CDPRelayServer {
// Simulate auto-attach behavior with real target info
if (!this._extensionConnection)
throw new Error('Extension not connected. Please ensure the Chrome extension is installed and connected to the extension endpoint before connecting Playwright.');
const { targetInfo } = await this._extensionConnection.send('attachToTab', { });
const { targetInfo } = await this._extensionConnection.send({ method: 'attachToTab' });
this._connectedTabInfo = {
targetInfo,
sessionId: `pw-tab-${this._nextSessionId++}`,
@@ -244,7 +229,7 @@ export class CDPRelayServer {
},
waitingForDebugger: false
}
});
} satisfies CDPEvent);
return { };
}
case 'Target.getTargetInfo': {
@@ -260,29 +245,27 @@ export class CDPRelayServer {
// Top level sessionId is only passed between the relay and the client.
if (this._connectedTabInfo?.sessionId === sessionId)
sessionId = undefined;
return await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
return await this._extensionConnection.send({
method: 'forwardCDPCommand',
params: { sessionId, method, params }
});
}
private _sendToPlaywright(message: CDPResponse): void {
debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`);
private _sendToPlaywright(message: CDPResponse | CDPEvent): void {
const logMessage = 'method' in message && message.method
? message.method
: `response(id=${'id' in message ? message.id : 'unknown'})`;
debugLogger('→ Playwright:', logMessage);
this._playwrightConnection?.send(JSON.stringify(message));
}
}
type ExtensionResponse = {
id?: number;
method?: string;
params?: any;
result?: any;
error?: string;
};
class ExtensionConnection {
private readonly _ws: WebSocket;
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void, error: Error }>();
private _lastId = 0;
onmessage?: <M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) => void;
onmessage?: (message: ExtensionEventMessage) => void;
onclose?: (self: ExtensionConnection, reason: string) => void;
constructor(ws: WebSocket) {
@@ -292,12 +275,12 @@ class ExtensionConnection {
this._ws.on('error', this._onError.bind(this));
}
async send<M extends keyof ExtensionCommand>(method: M, params: ExtensionCommand[M]['params']): Promise<any> {
async send(command: Omit<ExtensionCommandMessage, 'id'>): Promise<any> {
if (this._ws.readyState !== ws.OPEN)
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
const id = ++this._lastId;
this._ws.send(JSON.stringify({ id, method, params }));
const error = new Error(`Protocol error: ${method}`);
this._ws.send(JSON.stringify({ id, ...command }));
const error = new Error(`Protocol error: ${command.method}`);
return new Promise((resolve, reject) => {
this._callbacks.set(id, { resolve, reject, error });
});
@@ -327,8 +310,8 @@ class ExtensionConnection {
}
}
private _handleParsedMessage(object: ExtensionResponse) {
if (object.id && this._callbacks.has(object.id)) {
private _handleParsedMessage(object: ExtensionMessage) {
if ('id' in object && this._callbacks.has(object.id)) {
const callback = this._callbacks.get(object.id)!;
this._callbacks.delete(object.id);
if (object.error) {
@@ -338,10 +321,10 @@ class ExtensionConnection {
} else {
callback.resolve(object.result);
}
} else if (object.id) {
} else if ('id' in object) {
debugLogger('← Extension: unexpected response', object);
} else {
this.onmessage?.(object.method! as keyof ExtensionEvents, object.params);
this.onmessage?.(object as ExtensionEventMessage);
}
}
@@ -27,7 +27,7 @@ import { BrowserContextFactory, ClientInfo } from './types.js';
const debugLogger = debug('pw:mcp:relay');
export async function createExtensionContext(abortSignal: AbortSignal, ): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
export async function startRelayServer(abortSignal: AbortSignal, ){
// Merge obtainBrowser into this function
const httpServer = await startHttpServer({ port: 9988 });
if (abortSignal.aborted) {
@@ -41,16 +41,16 @@ export async function createExtensionContext(abortSignal: AbortSignal, ): Promis
// TODO simply call fetch. it was creatign a full fledged browser just to fetch an url previously. CRAZY
// await cdpRelayServer.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName);
// await waitForExtension()
return {}
const browser = await playwright.chromium.connectOverCDP(cdpRelayServer.cdpEndpoint());
return { cdpRelayServer }
// const browser = await playwright.chromium.connectOverCDP(cdpRelayServer.cdpEndpoint());
return {
browserContext: browser.contexts()[0],
close: async () => {
debugLogger('close() called for browser context');
await browser.close();
}
};
// return {
// browserContext: browser.contexts()[0],
// close: async () => {
// debugLogger('close() called for browser context');
// await browser.close();
// }
// };
}
+28 -18
View File
@@ -18,25 +18,35 @@
// extension version should be compatible with the old MCP clients.
export const VERSION = 1;
export type ExtensionCommand = {
'attachToTab': {
params: {};
};
'forwardCDPCommand': {
params: {
method: string,
sessionId?: string
params?: any,
export type ExtensionCommandMessage =
| {
id: number;
method: 'attachToTab';
params?: {};
}
| {
id: number;
method: 'forwardCDPCommand';
params: {
method: string;
sessionId?: string;
params?: any;
};
};
export type ExtensionResponseMessage = {
id: number;
result?: any;
error?: string;
};
export type ExtensionEventMessage = {
method: 'forwardCDPEvent';
params: {
method: string;
sessionId?: string;
params?: any;
};
};
export type ExtensionEvents = {
'forwardCDPEvent': {
params: {
method: string,
sessionId?: string
params?: any,
};
};
};
export type ExtensionMessage = ExtensionResponseMessage | ExtensionEventMessage;
+1 -1
View File
@@ -1,7 +1,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
import { Page, Browser, BrowserContext, chromium } from 'patchright-core'
import { Page, Browser, BrowserContext, chromium } from 'playwright-core'
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
+12
View File
@@ -28,6 +28,10 @@ importers:
version: 4.0.8(@types/node@24.10.1)(@vitest/ui@4.0.8)(tsx@4.20.6)
extension-playwright:
dependencies:
playwriter:
specifier: workspace:*
version: link:../playwriter
devDependencies:
'@types/chrome':
specifier: ^0.0.315
@@ -52,6 +56,9 @@ importers:
'@modelcontextprotocol/sdk':
specifier: ^1.21.1
version: 1.21.1
devtools-protocol:
specifier: ^0.0.1543509
version: 0.0.1543509
playwright-core:
specifier: ^1.56.1
version: 1.56.1
@@ -803,6 +810,9 @@ packages:
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
engines: {node: '>=8'}
devtools-protocol@0.0.1543509:
resolution: {integrity: sha512-JlHmIPtgDeqcL2uwPA7xryDNSb1ug9h11IexYQUG98vcyV6/L3taLRECgUk/B/7yQhXC/sgBzKj9kaB+bxmdWg==}
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -2310,6 +2320,8 @@ snapshots:
detect-indent@6.1.0: {}
devtools-protocol@0.0.1543509: {}
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0