Skip to content
Open
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
65 changes: 62 additions & 3 deletions packages/playwright-cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,15 @@ const originalConnectOverCDP = playwright.chromium.connectOverCDP;
// HACK this is a major hack, but we need it to make playwright-mcp and stagehand work without modifying their code extensively.
// Both playwright-mcp and stagehand use playwright.chromium.connectOverCDP if a CDP endpoint is passed,
// so we need to override it to use our own connectOverCDP implementation.
(playwright.chromium as any).connectOverCDP = (endpointURLOrOptions: (ConnectOverCDPOptions & { wsEndpoint?: string }) | string) => {
(playwright.chromium as any).connectOverCDP = (endpointURLOrOptions: (ConnectOverCDPOptions & { wsEndpoint?: string }) | string, options?: ConnectOverCDPOptions) => {
const connectOptions = typeof endpointURLOrOptions === 'string' ? options : endpointURLOrOptions;
const wsEndpoint = typeof endpointURLOrOptions === 'string' ? endpointURLOrOptions : endpointURLOrOptions.wsEndpoint ?? endpointURLOrOptions.endpointURL;
if (!wsEndpoint)
throw new Error('No wsEndpoint provided');

if (isExternalWebSocketEndpoint(wsEndpoint))
return connectToExternalWebSocket(wsEndpoint, connectOptions);

const wsUrl = new URL(wsEndpoint);
// by default, playwright.chromium.connectOverCDP enforces persistent to true (the default behavior upstream)
if (!wsUrl.searchParams.has('persistent'))
Expand All @@ -45,6 +50,60 @@ const originalConnectOverCDP = playwright.chromium.connectOverCDP;
: launch(wsUrl.toString());
};

function isExternalWebSocketEndpoint(endpoint: string): boolean {
return endpoint.startsWith('ws://') || endpoint.startsWith('wss://');
}

async function connectToExternalWebSocket(wsEndpoint: string, options?: ConnectOverCDPOptions): Promise<Browser> {
resetMonotonicTime();
const webSocket = new WebSocket(wsEndpoint);
await waitForExternalWebSocketOpen(webSocket, options?.timeout ?? 30_000);
const sessionId = new URL(wsEndpoint).searchParams.get('browser_session') ?? '';
const transport = new WebSocketTransport(webSocket, sessionId);
const browserOptions = options && {
isLocal: options.isLocal,
logger: options.logger,
slowMo: options.slowMo,
timeout: options.timeout,
};
return await createBrowser(transport, { persistent: true }, browserOptions);
}

function waitForExternalWebSocketOpen(webSocket: WebSocket, timeout: number): Promise<void> {
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (timeoutId)
clearTimeout(timeoutId);
webSocket.removeEventListener('open', onOpen);
webSocket.removeEventListener('error', onError);
webSocket.removeEventListener('close', onClose);
};
const onOpen = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error('External CDP WebSocket connection failed'));
};
const onClose = () => {
cleanup();
reject(new Error('External CDP WebSocket closed before opening'));
};
webSocket.addEventListener('open', onOpen);
webSocket.addEventListener('error', onError);
webSocket.addEventListener('close', onClose);
if (timeout > 0) {
timeoutId = setTimeout(() => {
cleanup();
webSocket.close();
reject(new Error(`Timed out after ${timeout}ms while connecting to external CDP endpoint`));
}, timeout);
}
});
}

async function connectDevtools(endpoint: BrowserEndpoint, options: { sessionId?: string, persistent?: boolean, browser?: string }): Promise<WebSocket> {
resetMonotonicTime();
const url = new URL(`${HTTP_FAKE_HOST}/v1/devtools/browser${options.sessionId ? `/${options.sessionId}` : ''}`);
Expand Down Expand Up @@ -94,12 +153,12 @@ export function endpointURLString(binding: BrowserWorker | BrowserBindingKey, op
return url.toString();
}

async function createBrowser(transport: WebSocketTransport, options?: { persistent?: boolean }): Promise<Browser> {
async function createBrowser(transport: WebSocketTransport, options?: { persistent?: boolean }, connectOptions?: Pick<ConnectOverCDPOptions, 'isLocal' | 'logger' | 'slowMo' | 'timeout'>): Promise<Browser> {
return await transportZone.run(transport, async () => {
const url = new URL(WS_FAKE_HOST);
if (options?.persistent)
url.searchParams.set('persistent', 'true');
const browser = await originalConnectOverCDP.call(playwright.chromium, url.toString(), {}) as Browser;
const browser = await originalConnectOverCDP.call(playwright.chromium, url.toString(), connectOptions ?? {}) as Browser;
// sessionId is undefined for kitesurf browsers
// The public types express that through the SessionlessBrowser overload of launch().
browser.sessionId = () => transport.sessionId as string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,56 @@ test(`should create browser with persistent context on playwright.chromium.conne
await browser.close();
});

test(`should route external WebSocket endpoints through CDP`, async ({ playwright }) => {
const originalWebSocket = globalThis.WebSocket;
class RejectingWebSocket {
addEventListener(type: string, listener: (event: unknown) => void) {
if (type === 'error')
queueMicrotask(() => listener(new Error('external CDP test')));
}
removeEventListener() {}
}
globalThis.WebSocket = RejectingWebSocket as unknown as typeof WebSocket;
try {
await expect(playwright.chromium.connectOverCDP('wss://example.test/devtools/browser')).rejects.toThrow('connection failed');
} finally {
globalThis.WebSocket = originalWebSocket;
}
});

test(`should reject when external WebSocket closes before opening`, async ({ playwright }) => {
const originalWebSocket = globalThis.WebSocket;
class ClosingWebSocket {
addEventListener(type: string, listener: (event: unknown) => void) {
if (type === 'close')
queueMicrotask(() => listener(new Event('close')));
}
removeEventListener() {}
close() {}
}
globalThis.WebSocket = ClosingWebSocket as unknown as typeof WebSocket;
try {
await expect(playwright.chromium.connectOverCDP('wss://example.test/devtools/browser')).rejects.toThrow('closed before opening');
} finally {
globalThis.WebSocket = originalWebSocket;
}
});

test(`should time out while opening an external WebSocket`, async ({ playwright }) => {
const originalWebSocket = globalThis.WebSocket;
class HangingWebSocket {
addEventListener() {}
removeEventListener() {}
close() {}
}
globalThis.WebSocket = HangingWebSocket as unknown as typeof WebSocket;
try {
await expect(playwright.chromium.connectOverCDP('wss://example.test/devtools/browser', { timeout: 1 })).rejects.toThrow('Timed out');
} finally {
globalThis.WebSocket = originalWebSocket;
}
});

test(`should launch browser with no persistent context by default`, async ({ binding }) => {
const url = endpointURLString(binding);
const browser = await launch(url);
Expand Down