Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 89 additions & 21 deletions src/DevToolsConnectionAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,106 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {ConnectionTransport as DevToolsConnectionTransport} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
import type {CDPConnection as devtools} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';

import {type ConnectionTransport} from './third_party/index.js';
import type * as puppeteer from './third_party/index.js';
import {CDPSessionEvent} from './third_party/index.js';

/**
* Allows a puppeteer {@link ConnectionTransport} to act like a DevTools {@link Connection}.
* This class makes a puppeteer connection look like DevTools CDPConnection.
*
* Since we connect "root" DevTools targets to specific pages, we scope everything to a puppeteer CDP session.
*
* We don't have to recursively listen for 'sessionattached' as the "root" CDP session sees all child session attached
* events, regardless how deeply nested they are.
*/
export class DevToolsConnectionAdapter extends DevToolsConnectionTransport {
#transport: ConnectionTransport | null;
#onDisconnect: ((arg0: string) => void) | null = null;

constructor(transport: ConnectionTransport) {
super();
this.#transport = transport;
this.#transport.onclose = () => this.#onDisconnect?.('');
this.#transport.onmessage = msg => this.onMessage?.(msg);
export class PuppeteerDevToolsConnection implements devtools.CDPConnection {
readonly #connection: puppeteer.Connection;
readonly #observers = new Set<devtools.CDPConnectionObserver>();
readonly #sessionEventHandlers = new Map<
string,
puppeteer.Handler<unknown>
>();

constructor(session: puppeteer.CDPSession) {
this.#connection = session.connection()!;

session.on(
CDPSessionEvent.SessionAttached,
this.#startForwardingCdpEvents.bind(this),
);
session.on(
CDPSessionEvent.SessionDetached,
this.#stopForwardingCdpEvents.bind(this),
);

this.#startForwardingCdpEvents(session);
}

send<T extends devtools.Command>(
method: T,
params: devtools.CommandParams<T>,
sessionId: string | undefined,
): Promise<{result: devtools.CommandResult<T>} | {error: devtools.CDPError}> {
if (sessionId === undefined) {
throw new Error(
'Attempting to send on the root session. This must not happen',
);
}
const session = this.#connection.session(sessionId);
if (!session) {
throw new Error('Unknown session ' + sessionId);
}
// Rolled protocol version between puppeteer and DevTools doesn't necessarily match
/* eslint-disable @typescript-eslint/no-explicit-any */
return session
.send(method as any, params)
.then(result => ({result}))
.catch(error => ({error})) as any;
/* eslint-enable @typescript-eslint/no-explicit-any */
}

observe(observer: devtools.CDPConnectionObserver): void {
this.#observers.add(observer);
}

override setOnMessage(onMessage: (arg0: object | string) => void): void {
this.onMessage = onMessage;
unobserve(observer: devtools.CDPConnectionObserver): void {
this.#observers.delete(observer);
}

override setOnDisconnect(onDisconnect: (arg0: string) => void): void {
this.#onDisconnect = onDisconnect;
#startForwardingCdpEvents(session: puppeteer.CDPSession): void {
const handler = this.#handleEvent.bind(
this,
session.id(),
) as puppeteer.Handler<unknown>;
this.#sessionEventHandlers.set(session.id(), handler);
session.on('*', handler);
}

override sendRawMessage(message: string): void {
this.#transport?.send(message);
#stopForwardingCdpEvents(session: puppeteer.CDPSession): void {
const handler = this.#sessionEventHandlers.get(session.id());
if (handler) {
session.off('*', handler);
}
}

override async disconnect(): Promise<void> {
this.#transport?.close();
this.#transport = null;
#handleEvent(
sessionId: string,
type: string | symbol | number,
event: any, // eslint-disable-line @typescript-eslint/no-explicit-any
): void {
if (
typeof type === 'string' &&
type !== CDPSessionEvent.SessionAttached &&
type !== CDPSessionEvent.SessionDetached
) {
this.#observers.forEach(observer =>
observer.onEvent({
method: type as devtools.Event,
sessionId,
params: event,
}),
);
}
}
}
6 changes: 5 additions & 1 deletion src/third_party/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ export {
type TextContent,
} from '@modelcontextprotocol/sdk/types.js';
export {z as zod} from 'zod';
export {Locator, PredefinedNetworkConditions} from 'puppeteer-core';
export {
Locator,
PredefinedNetworkConditions,
CDPSessionEvent,
} from 'puppeteer-core';
export {default as puppeteer} from 'puppeteer-core';
export type * from 'puppeteer-core';
export type {CdpPage} from 'puppeteer-core/internal/cdp/Page.js';