new code
This commit is contained in:
@@ -30,8 +30,12 @@
|
||||
"vitest": "^4.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.6",
|
||||
"@hono/node-ws": "^1.2.0",
|
||||
"@modelcontextprotocol/sdk": "^1.21.1",
|
||||
"chalk": "^5.6.2",
|
||||
"devtools-protocol": "^0.0.1543509",
|
||||
"hono": "^4.10.6",
|
||||
"playwright-core": "^1.56.1",
|
||||
"user-agents": "^1.1.669",
|
||||
"ws": "^8.18.3",
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { startRelayServer } from '../src/extension/extensionContextFactory'
|
||||
import { startRelayServer } from '../src/extension/extension-context-factory.js'
|
||||
|
||||
async function main() {
|
||||
const controller = new AbortController()
|
||||
const { cdpRelayServer } = await startRelayServer(
|
||||
controller.signal,
|
||||
)
|
||||
const server = await startRelayServer({ port: 9988 })
|
||||
|
||||
console.log('Server running. Press Ctrl+C to stop.')
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nShutting down...')
|
||||
server.close()
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
main().catch(console.error)
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { Hono } from 'hono'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { createNodeWebSocket } from '@hono/node-ws'
|
||||
import type { WSContext } from 'hono/ws'
|
||||
import type { Protocol } from '../cdp-types.js'
|
||||
import type { CDPCommand, CDPResponse, CDPEvent } from '../cdp-types.js'
|
||||
import type { ExtensionMessage, ExtensionEventMessage } from './protocol.js'
|
||||
import chalk from 'chalk'
|
||||
|
||||
type ConnectedTarget = {
|
||||
sessionId: string
|
||||
targetId: string
|
||||
targetInfo: Protocol.Target.TargetInfo
|
||||
}
|
||||
|
||||
type CDPEventWithSource = CDPEvent & {
|
||||
__serverGenerated?: boolean
|
||||
}
|
||||
|
||||
export async function startRelayServer({ port = 9988 }: { port?: number } = {}) {
|
||||
const connectedTargets = new Map<string, ConnectedTarget>()
|
||||
|
||||
let playwrightWs: WSContext | null = null
|
||||
let extensionWs: WSContext | null = null
|
||||
|
||||
const extensionPendingRequests = new Map<number, {
|
||||
resolve: (result: any) => void
|
||||
reject: (error: Error) => void
|
||||
}>()
|
||||
let extensionMessageId = 0
|
||||
|
||||
function sendToPlaywright(message: CDPResponse | CDPEvent, source: 'extension' | 'server' = 'extension') {
|
||||
if (!playwrightWs) {
|
||||
return
|
||||
}
|
||||
|
||||
const messageToSend = source === 'server' && 'method' in message
|
||||
? { ...message, __serverGenerated: true }
|
||||
: message
|
||||
|
||||
if ('method' in message) {
|
||||
const color = source === 'server' ? chalk.magenta : chalk.green
|
||||
console.log(color('→ Playwright:'), message.method, source === 'server' ? chalk.gray('(server-generated)') : '')
|
||||
}
|
||||
|
||||
playwrightWs.send(JSON.stringify(messageToSend))
|
||||
}
|
||||
|
||||
async function sendToExtension({ method, params }: { method: string; params?: any }) {
|
||||
if (!extensionWs) {
|
||||
throw new Error('Extension not connected')
|
||||
}
|
||||
|
||||
const id = ++extensionMessageId
|
||||
const message = { id, method, params }
|
||||
|
||||
extensionWs.send(JSON.stringify(message))
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
extensionPendingRequests.set(id, { resolve, reject })
|
||||
})
|
||||
}
|
||||
|
||||
async function routeCdpCommand({ method, params, sessionId }: { method: string; params: any; sessionId?: string }) {
|
||||
switch (method) {
|
||||
case 'Browser.getVersion': {
|
||||
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 {}
|
||||
}
|
||||
|
||||
case 'Target.setAutoAttach': {
|
||||
if (sessionId) {
|
||||
break
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
case 'Target.getTargetInfo': {
|
||||
const targetId = params?.targetId
|
||||
|
||||
if (targetId) {
|
||||
for (const target of connectedTargets.values()) {
|
||||
if (target.targetId === targetId) {
|
||||
return { targetInfo: target.targetInfo }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
const target = connectedTargets.get(sessionId)
|
||||
if (target) {
|
||||
return { targetInfo: target.targetInfo }
|
||||
}
|
||||
}
|
||||
|
||||
const firstTarget = Array.from(connectedTargets.values())[0]
|
||||
return { targetInfo: firstTarget?.targetInfo }
|
||||
}
|
||||
|
||||
case 'Target.getTargets': {
|
||||
return {
|
||||
targetInfos: Array.from(connectedTargets.values()).map((t) => ({
|
||||
...t.targetInfo,
|
||||
attached: true
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
case 'Target.closeTarget': {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return await sendToExtension({
|
||||
method: 'forwardCDPCommand',
|
||||
params: { sessionId, method, params }
|
||||
})
|
||||
}
|
||||
|
||||
const app = new Hono()
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.text('OK')
|
||||
})
|
||||
|
||||
app.get('/cdp', upgradeWebSocket(() => {
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
if (playwrightWs) {
|
||||
console.log('Rejecting second Playwright connection')
|
||||
ws.close(1000, 'Another CDP client already connected')
|
||||
return
|
||||
}
|
||||
|
||||
playwrightWs = ws
|
||||
console.log('Playwright connected')
|
||||
},
|
||||
|
||||
async onMessage(event, ws) {
|
||||
let message: CDPCommand
|
||||
|
||||
try {
|
||||
message = JSON.parse(event.data.toString())
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(chalk.cyan('← Playwright:'), `${message.method} (id=${message.id})`)
|
||||
|
||||
const { id, sessionId, method, params } = message
|
||||
|
||||
if (!extensionWs) {
|
||||
sendToPlaywright({
|
||||
id,
|
||||
sessionId,
|
||||
error: { message: 'Extension not connected' }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result: any = await routeCdpCommand({ method, params, sessionId })
|
||||
|
||||
if (method === 'Target.setAutoAttach' && !sessionId) {
|
||||
for (const target of connectedTargets.values()) {
|
||||
sendToPlaywright({
|
||||
method: 'Target.attachedToTarget',
|
||||
params: {
|
||||
sessionId: target.sessionId,
|
||||
targetInfo: {
|
||||
...target.targetInfo,
|
||||
attached: true
|
||||
},
|
||||
waitingForDebugger: false
|
||||
}
|
||||
} satisfies CDPEvent, 'server')
|
||||
}
|
||||
}
|
||||
|
||||
sendToPlaywright({ id, sessionId, result })
|
||||
} catch (e) {
|
||||
console.error('Error handling CDP command:', e)
|
||||
sendToPlaywright({
|
||||
id,
|
||||
sessionId,
|
||||
error: { message: (e as Error).message }
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
onClose() {
|
||||
if (playwrightWs) {
|
||||
console.log('Playwright disconnected')
|
||||
playwrightWs = null
|
||||
}
|
||||
},
|
||||
|
||||
onError(event) {
|
||||
console.error('Playwright WebSocket error:', event)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/extension', upgradeWebSocket(() => {
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
if (extensionWs) {
|
||||
console.log('Rejecting second extension connection')
|
||||
ws.close(1000, 'Another extension connection already established')
|
||||
return
|
||||
}
|
||||
|
||||
extensionWs = ws
|
||||
console.log('Extension connected')
|
||||
},
|
||||
|
||||
async onMessage(event, ws) {
|
||||
let message: ExtensionMessage
|
||||
|
||||
try {
|
||||
message = JSON.parse(event.data.toString())
|
||||
} catch {
|
||||
ws.close(1000, 'Invalid JSON')
|
||||
return
|
||||
}
|
||||
|
||||
if ('id' in message) {
|
||||
const pending = extensionPendingRequests.get(message.id)
|
||||
if (!pending) {
|
||||
console.log('Unexpected response with id:', message.id)
|
||||
return
|
||||
}
|
||||
|
||||
extensionPendingRequests.delete(message.id)
|
||||
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error))
|
||||
} else {
|
||||
pending.resolve(message.result)
|
||||
}
|
||||
} else {
|
||||
const extensionEvent = message as ExtensionEventMessage
|
||||
|
||||
if (extensionEvent.method !== 'forwardCDPEvent') {
|
||||
return
|
||||
}
|
||||
|
||||
const { method, params, sessionId } = extensionEvent.params
|
||||
|
||||
console.log(chalk.yellow('← Extension:'), method)
|
||||
|
||||
if (method === 'Target.attachedToTarget') {
|
||||
const targetParams = params as Protocol.Target.AttachedToTargetEvent
|
||||
connectedTargets.set(targetParams.sessionId, {
|
||||
sessionId: targetParams.sessionId,
|
||||
targetId: targetParams.targetInfo.targetId,
|
||||
targetInfo: targetParams.targetInfo
|
||||
})
|
||||
|
||||
sendToPlaywright({
|
||||
method: 'Target.attachedToTarget',
|
||||
params: targetParams
|
||||
} as CDPEvent, 'extension')
|
||||
} else if (method === 'Target.detachedFromTarget') {
|
||||
const detachParams = params as Protocol.Target.DetachedFromTargetEvent
|
||||
connectedTargets.delete(detachParams.sessionId)
|
||||
|
||||
sendToPlaywright({
|
||||
method: 'Target.detachedFromTarget',
|
||||
params: detachParams
|
||||
} as CDPEvent, 'extension')
|
||||
} else {
|
||||
sendToPlaywright({
|
||||
sessionId,
|
||||
method,
|
||||
params
|
||||
} as CDPEvent, 'extension')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onClose() {
|
||||
console.log('Extension disconnected')
|
||||
|
||||
for (const pending of extensionPendingRequests.values()) {
|
||||
pending.reject(new Error('Extension connection closed'))
|
||||
}
|
||||
extensionPendingRequests.clear()
|
||||
|
||||
extensionWs = null
|
||||
connectedTargets.clear()
|
||||
|
||||
if (playwrightWs) {
|
||||
playwrightWs.close(1000, 'Extension disconnected')
|
||||
playwrightWs = null
|
||||
}
|
||||
},
|
||||
|
||||
onError(event) {
|
||||
console.error('Extension WebSocket error:', event)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const server = serve({ fetch: app.fetch, port })
|
||||
injectWebSocket(server)
|
||||
|
||||
const wsHost = `ws://localhost:${port}`
|
||||
const cdpEndpoint = `${wsHost}/cdp`
|
||||
const extensionEndpoint = `${wsHost}/extension`
|
||||
|
||||
console.log('CDP relay server started')
|
||||
console.log('Extension endpoint:', extensionEndpoint)
|
||||
console.log('CDP endpoint:', cdpEndpoint)
|
||||
|
||||
return {
|
||||
cdpEndpoint,
|
||||
extensionEndpoint,
|
||||
close() {
|
||||
playwrightWs?.close(1000, 'Server stopped')
|
||||
extensionWs?.close(1000, 'Server stopped')
|
||||
server.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* WebSocket server that bridges Playwright MCP and Chrome Extension
|
||||
*
|
||||
* Endpoints:
|
||||
* - /cdp/guid - Full CDP interface for Playwright MCP
|
||||
* - /extension/guid - Extension connection for chrome.debugger forwarding
|
||||
*/
|
||||
|
||||
import net from 'net';
|
||||
|
||||
import http from 'http';
|
||||
|
||||
import { debug, ws, wsServer } from 'playwright-core/lib/utilsBundle';
|
||||
|
||||
import { ManualPromise } from 'playwright-core/lib/utils';
|
||||
|
||||
|
||||
|
||||
import type { WebSocket, WebSocketServer } from 'playwright-core/lib/utilsBundle';
|
||||
import type websocket from 'ws';
|
||||
import type { ExtensionCommandMessage, ExtensionEventMessage, ExtensionMessage } from './protocol.js';
|
||||
import type { CDPCommand, CDPResponse, CDPEvent, Protocol } from '../cdp-types.js';
|
||||
|
||||
|
||||
const debugLogger = debug('pw:mcp:relay');
|
||||
|
||||
interface ConnectedTarget {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
targetInfo: Protocol.Target.TargetInfo;
|
||||
}
|
||||
|
||||
export class CDPRelayServer {
|
||||
private _wsHost: string;
|
||||
|
||||
private _cdpPath: string;
|
||||
private _extensionPath: string;
|
||||
private _wss: WebSocketServer;
|
||||
private _playwrightConnection: WebSocket | null = null;
|
||||
private _extensionConnection: ExtensionConnection | null = null;
|
||||
private _connectedTargets: Map<string, ConnectedTarget> = new Map();
|
||||
private _extensionConnectionPromise!: ManualPromise<void>;
|
||||
|
||||
constructor(server: http.Server, ) {
|
||||
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
||||
|
||||
|
||||
this._cdpPath = `/cdp`;
|
||||
this._extensionPath = `/extension`;
|
||||
|
||||
this._resetExtensionConnection();
|
||||
this._wss = new wsServer({ server });
|
||||
this._wss.on('connection', (ws: WebSocket, request: http.IncomingMessage) => {
|
||||
const url = new URL(`http://localhost${request.url}`);
|
||||
debugLogger(`New connection to ${url.pathname}`);
|
||||
if (url.pathname === this._cdpPath) {
|
||||
this._handlePlaywrightConnection(ws);
|
||||
} else if (url.pathname === this._extensionPath) {
|
||||
if (this._extensionConnection) {
|
||||
ws.close(1000, 'Another extension connection already established');
|
||||
return;
|
||||
}
|
||||
this._extensionConnection = new ExtensionConnection(ws);
|
||||
this._extensionConnection.onclose = (c, reason) => {
|
||||
debugLogger('Extension WebSocket closed:', reason, c === this._extensionConnection);
|
||||
if (this._extensionConnection !== c)
|
||||
return;
|
||||
this._resetExtensionConnection();
|
||||
this._closePlaywrightConnection(`Extension disconnected: ${reason}`);
|
||||
};
|
||||
this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
|
||||
this._extensionConnectionPromise.resolve();
|
||||
} else {
|
||||
debugLogger(`Invalid path: ${url.pathname}`);
|
||||
ws.close(4004, 'Invalid path');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cdpEndpoint() {
|
||||
return `${this._wsHost}${this._cdpPath}`;
|
||||
}
|
||||
|
||||
extensionEndpoint() {
|
||||
return `${this._wsHost}${this._extensionPath}`;
|
||||
}
|
||||
|
||||
|
||||
stop(): void {
|
||||
this.closeConnections('Server stopped');
|
||||
this._wss.close();
|
||||
}
|
||||
|
||||
closeConnections(reason: string) {
|
||||
this._closePlaywrightConnection(reason);
|
||||
this._closeExtensionConnection(reason);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private _handlePlaywrightConnection(ws: WebSocket): void {
|
||||
if (this._playwrightConnection) {
|
||||
debugLogger('Rejecting second Playwright connection');
|
||||
ws.close(1000, 'Another CDP client already connected');
|
||||
return;
|
||||
}
|
||||
this._playwrightConnection = ws;
|
||||
ws.on('message', async data => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
await this._handlePlaywrightMessage(message);
|
||||
} catch (error: any) {
|
||||
debugLogger(`Error while handling Playwright message\n${data.toString()}\n`, error);
|
||||
}
|
||||
});
|
||||
ws.on('close', () => {
|
||||
if (this._playwrightConnection !== ws)
|
||||
return;
|
||||
this._playwrightConnection = null;
|
||||
debugLogger('Playwright WebSocket closed - extension stays connected');
|
||||
});
|
||||
ws.on('error', error => {
|
||||
debugLogger('Playwright WebSocket error:', error);
|
||||
});
|
||||
debugLogger('Playwright MCP connected');
|
||||
}
|
||||
|
||||
private _closeExtensionConnection(reason: string) {
|
||||
this._extensionConnection?.close(reason);
|
||||
this._extensionConnectionPromise.reject(new Error(reason));
|
||||
this._resetExtensionConnection();
|
||||
}
|
||||
|
||||
private _resetExtensionConnection() {
|
||||
this._connectedTargets.clear();
|
||||
this._extensionConnection = null;
|
||||
this._extensionConnectionPromise = new ManualPromise();
|
||||
void this._extensionConnectionPromise.catch(logUnhandledError);
|
||||
}
|
||||
|
||||
|
||||
private _closePlaywrightConnection(reason: string) {
|
||||
if (this._playwrightConnection?.readyState === ws.OPEN)
|
||||
this._playwrightConnection.close(1000, reason);
|
||||
this._playwrightConnection = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private _handleExtensionMessage(message: ExtensionEventMessage) {
|
||||
if (message.method === 'forwardCDPEvent') {
|
||||
const { method, params, sessionId } = message.params;
|
||||
|
||||
if (method === 'Target.attachedToTarget') {
|
||||
const targetParams = params as Protocol.Target.AttachedToTargetEvent;
|
||||
this._connectedTargets.set(targetParams.sessionId, {
|
||||
sessionId: targetParams.sessionId,
|
||||
targetId: targetParams.targetInfo.targetId,
|
||||
targetInfo: targetParams.targetInfo
|
||||
});
|
||||
|
||||
debugLogger('\x1b[33m← Extension:\x1b[0m', `Target.attachedToTarget sessionId=${targetParams.sessionId}, targetId=${targetParams.targetInfo.targetId}`);
|
||||
|
||||
this._sendToPlaywright({
|
||||
method: 'Target.attachedToTarget',
|
||||
params: targetParams
|
||||
} as CDPEvent);
|
||||
|
||||
} else if (method === 'Target.detachedFromTarget') {
|
||||
const detachParams = params as Protocol.Target.DetachedFromTargetEvent;
|
||||
this._connectedTargets.delete(detachParams.sessionId);
|
||||
|
||||
debugLogger('\x1b[33m← Extension:\x1b[0m', `Target.detachedFromTarget sessionId=${detachParams.sessionId}`);
|
||||
|
||||
this._sendToPlaywright({
|
||||
method: 'Target.detachedFromTarget',
|
||||
params: detachParams
|
||||
} as CDPEvent);
|
||||
|
||||
} else {
|
||||
this._sendToPlaywright({
|
||||
sessionId,
|
||||
method,
|
||||
params
|
||||
} as CDPEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _handlePlaywrightMessage(message: CDPCommand): Promise<void> {
|
||||
debugLogger('\x1b[36m← Playwright:\x1b[0m', `${message.method} (id=${message.id})`);
|
||||
const { id, sessionId, method, params } = message;
|
||||
|
||||
const forwardToExtension = async (method: string, params: any, sessionId: string | undefined): Promise<any> => {
|
||||
if (!this._extensionConnection)
|
||||
throw new Error('Extension not connected');
|
||||
|
||||
return await this._extensionConnection.send({
|
||||
method: 'forwardCDPCommand',
|
||||
params: { sessionId, method, params }
|
||||
});
|
||||
};
|
||||
|
||||
const handleCDPCommand = async (method: string, params: any, sessionId: string | undefined): Promise<any> => {
|
||||
switch (method) {
|
||||
case 'Browser.getVersion': {
|
||||
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 { };
|
||||
}
|
||||
case 'Target.setAutoAttach': {
|
||||
if (sessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
debugLogger('Target.setAutoAttach received (manual attach mode)');
|
||||
debugLogger('Sending Target.attachedToTarget events for existing targets:', this._connectedTargets.size);
|
||||
|
||||
for (const target of this._connectedTargets.values()) {
|
||||
debugLogger('Sending Target.attachedToTarget for sessionId:', target.sessionId, 'targetId:', target.targetId);
|
||||
this._sendToPlaywright({
|
||||
method: 'Target.attachedToTarget',
|
||||
params: {
|
||||
sessionId: target.sessionId,
|
||||
targetInfo: {
|
||||
...target.targetInfo,
|
||||
attached: true
|
||||
},
|
||||
waitingForDebugger: false
|
||||
}
|
||||
} satisfies CDPEvent);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
case 'Target.getTargetInfo': {
|
||||
const targetId = params?.targetId;
|
||||
|
||||
if (targetId) {
|
||||
for (const target of this._connectedTargets.values()) {
|
||||
if (target.targetId === targetId) {
|
||||
return { targetInfo: target.targetInfo };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
const target = this._connectedTargets.get(sessionId);
|
||||
if (target) {
|
||||
return { targetInfo: target.targetInfo };
|
||||
}
|
||||
}
|
||||
|
||||
const firstTarget = this._connectedTargets.values().next().value;
|
||||
return { targetInfo: firstTarget?.targetInfo };
|
||||
}
|
||||
case 'Target.getTargets': {
|
||||
return {
|
||||
targetInfos: Array.from(this._connectedTargets.values()).map(t => ({
|
||||
...t.targetInfo,
|
||||
attached: true,
|
||||
}))
|
||||
};
|
||||
}
|
||||
case 'Target.closeTarget': {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return await forwardToExtension(method, params, sessionId);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handleCDPCommand(method, params, sessionId);
|
||||
this._sendToPlaywright({ id, sessionId, result });
|
||||
} catch (e) {
|
||||
debugLogger('\x1b[31mError in the extension:\x1b[0m', e);
|
||||
this._sendToPlaywright({
|
||||
id,
|
||||
sessionId,
|
||||
error: { message: (e as Error).message }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _sendToPlaywright(message: CDPResponse | CDPEvent): void {
|
||||
const logMessage = 'method' in message && message.method
|
||||
? message.method
|
||||
: `response(id=${'id' in message ? message.id : 'unknown'})`;
|
||||
debugLogger('\x1b[32m→ Playwright:\x1b[0m', logMessage);
|
||||
this._playwrightConnection?.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
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?: (message: ExtensionEventMessage) => void;
|
||||
onclose?: (self: ExtensionConnection, reason: string) => void;
|
||||
|
||||
constructor(ws: WebSocket) {
|
||||
this._ws = ws;
|
||||
|
||||
this._ws.on('message', (event: websocket.RawData) => {
|
||||
const eventData = event.toString();
|
||||
let parsedJson;
|
||||
try {
|
||||
parsedJson = JSON.parse(eventData);
|
||||
} catch (e: any) {
|
||||
debugLogger(`<closing ws> Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`);
|
||||
this._ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const 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) {
|
||||
const error = callback.error;
|
||||
error.message = object.error;
|
||||
callback.reject(error);
|
||||
} else {
|
||||
callback.resolve(object.result);
|
||||
}
|
||||
} else if ('id' in object) {
|
||||
debugLogger('← Extension: unexpected response', object);
|
||||
} else {
|
||||
this.onmessage?.(object as ExtensionEventMessage);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
handleParsedMessage(parsedJson);
|
||||
} catch (e: any) {
|
||||
debugLogger(`<closing ws> Closing websocket due to failed onmessage callback. eventData=${eventData} e=${e?.message}`);
|
||||
this._ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
this._ws.on('close', (event: websocket.CloseEvent) => {
|
||||
debugLogger(`<ws closed> code=${event.code} reason=${event.reason}`);
|
||||
this._dispose();
|
||||
this.onclose?.(this, event.reason);
|
||||
});
|
||||
|
||||
this._ws.on('error', (event: websocket.ErrorEvent) => {
|
||||
debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
|
||||
this._dispose();
|
||||
});
|
||||
}
|
||||
|
||||
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, ...command }));
|
||||
const error = new Error(`Protocol error: ${command.method}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
this._callbacks.set(id, { resolve, reject, error });
|
||||
});
|
||||
}
|
||||
|
||||
close(message: string) {
|
||||
debugLogger('closing extension connection:', message);
|
||||
if (this._ws.readyState === ws.OPEN)
|
||||
this._ws.close(1000, message);
|
||||
}
|
||||
|
||||
private _dispose() {
|
||||
for (const callback of this._callbacks.values())
|
||||
callback.reject(new Error('WebSocket closed'));
|
||||
this._callbacks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function httpAddressToString(address: string | net.AddressInfo | null): string {
|
||||
|
||||
if (!address)
|
||||
throw new Error('Invalid null address passeds to httpAddressToString');
|
||||
if (typeof address === 'string')
|
||||
return address;
|
||||
const resolvedPort = address.port;
|
||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
||||
resolvedHost = 'localhost';
|
||||
return `http://${resolvedHost}:${resolvedPort}`;
|
||||
}
|
||||
function logUnhandledError(e: unknown) {
|
||||
debugLogger('Unhandled promise rejection:', e instanceof Error ? e.stack || e.message : e);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { startRelayServer } from './cdp-relay.js'
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* 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 * as playwright from 'playwright-core';
|
||||
import { debug } from 'playwright-core/lib/utilsBundle';
|
||||
|
||||
import http from 'http';
|
||||
import net from 'net'
|
||||
|
||||
import { CDPRelayServer } from './cdpRelay.js';
|
||||
import { BrowserContextFactory, ClientInfo } from './types.js';
|
||||
|
||||
|
||||
|
||||
const debugLogger = debug('pw:mcp:relay');
|
||||
|
||||
export async function startRelayServer(abortSignal: AbortSignal, ){
|
||||
// Merge obtainBrowser into this function
|
||||
const httpServer = await startHttpServer({ port: 9988 });
|
||||
if (abortSignal.aborted) {
|
||||
httpServer.close();
|
||||
throw new Error(abortSignal.reason);
|
||||
}
|
||||
const cdpRelayServer = new CDPRelayServer(httpServer, );
|
||||
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
||||
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
||||
|
||||
// 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 { 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();
|
||||
// }
|
||||
// };
|
||||
}
|
||||
|
||||
|
||||
export async function startHttpServer(config: { host?: string, port?: number }, abortSignal?: AbortSignal): Promise<http.Server> {
|
||||
const { host, port } = config;
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
// Simple health check endpoint for extension to check if server is running
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('OK');
|
||||
});
|
||||
decorateServer(httpServer);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.on('error', reject);
|
||||
abortSignal?.addEventListener('abort', () => {
|
||||
httpServer.close();
|
||||
reject(new Error('Aborted'));
|
||||
});
|
||||
httpServer.listen(port, host, () => {
|
||||
resolve();
|
||||
httpServer.removeListener('error', reject);
|
||||
});
|
||||
});
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function decorateServer(server: net.Server) {
|
||||
const sockets = new Set<net.Socket>();
|
||||
server.on('connection', socket => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
});
|
||||
|
||||
const close = server.close;
|
||||
server.close = (callback?: (err?: Error) => void) => {
|
||||
for (const socket of sockets)
|
||||
socket.destroy();
|
||||
sockets.clear();
|
||||
return close.call(server, callback);
|
||||
};
|
||||
}
|
||||
@@ -1,52 +1,34 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Whenever the commands/events change, the version must be updated. The latest
|
||||
// extension version should be compatible with the old MCP clients.
|
||||
export const VERSION = 1;
|
||||
export const VERSION = 1
|
||||
|
||||
export type ExtensionCommandMessage =
|
||||
| {
|
||||
id: number;
|
||||
method: 'attachToTab';
|
||||
params?: {};
|
||||
id: number
|
||||
method: 'attachToTab'
|
||||
params?: object
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
method: 'forwardCDPCommand';
|
||||
id: number
|
||||
method: 'forwardCDPCommand'
|
||||
params: {
|
||||
method: string;
|
||||
sessionId?: string;
|
||||
params?: any;
|
||||
};
|
||||
};
|
||||
method: string
|
||||
sessionId?: string
|
||||
params?: any
|
||||
}
|
||||
}
|
||||
|
||||
export type ExtensionResponseMessage = {
|
||||
id: number;
|
||||
result?: any;
|
||||
error?: string;
|
||||
};
|
||||
id: number
|
||||
result?: any
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type ExtensionEventMessage = {
|
||||
method: 'forwardCDPEvent';
|
||||
method: 'forwardCDPEvent'
|
||||
params: {
|
||||
method: string;
|
||||
sessionId?: string;
|
||||
params?: any;
|
||||
};
|
||||
};
|
||||
method: string
|
||||
sessionId?: string
|
||||
params?: any
|
||||
}
|
||||
}
|
||||
|
||||
export type ExtensionMessage = ExtensionResponseMessage | ExtensionEventMessage;
|
||||
export type ExtensionMessage = ExtensionResponseMessage | ExtensionEventMessage
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import * as playwright from 'playwright-core';
|
||||
|
||||
|
||||
export type BrowserContextFactoryResult = {
|
||||
browserContext: playwright.BrowserContext;
|
||||
close: (afterClose: () => Promise<void>) => Promise<void>;
|
||||
};
|
||||
|
||||
export interface BrowserContextFactory {
|
||||
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<BrowserContextFactoryResult>;
|
||||
}
|
||||
|
||||
export type ClientInfo = {
|
||||
name: string;
|
||||
version: string;
|
||||
timestamp: number;
|
||||
};
|
||||
Reference in New Issue
Block a user