From 324a27a079010405efd7e2f91965489ce9c89e84 Mon Sep 17 00:00:00 2001 From: Dana Majid Date: Tue, 29 Jul 2025 16:29:23 +0200 Subject: [PATCH] feat: support connecting to ws directly This adds support for using any ws CDP based server to connect to, without breaking existing usage of the browser rendering api. Example usage with Browserbase: ``` import { Browserbase } from '@browserbasehq/sdk'; import { chromium } from '@cloudflare/playwright'; const bb = new Browserbase({ apiKey: env.BROWSERBASE_API_KEY }); const session = await bb.sessions.create({ projectId: env.BROWSERBASE_PROJECT_ID, }); const browser = await chromium.connectOverCDP(session.connectUrl); const defaultContext = browser.contexts()[0]; const page = defaultContext.pages()[0]; await page.goto('https://google.com'); page.close(); browser.close(); ``` --- .../src/cloudflare/webSocketTransport.ts | 14 +++++++- packages/playwright-cloudflare/src/index.ts | 34 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/playwright-cloudflare/src/cloudflare/webSocketTransport.ts b/packages/playwright-cloudflare/src/cloudflare/webSocketTransport.ts index eee13c144..22d1b0401 100644 --- a/packages/playwright-cloudflare/src/cloudflare/webSocketTransport.ts +++ b/packages/playwright-cloudflare/src/cloudflare/webSocketTransport.ts @@ -11,6 +11,7 @@ export class WebSocketTransport implements ConnectionTransport { private _ws: WebSocket; private _pingInterval: NodeJS.Timer; private _chunks: Uint8Array[] = []; + private _disableChunking: boolean; onmessage?: (message: ProtocolResponse) => void; onclose?: () => void; readonly sessionId: string; @@ -23,13 +24,20 @@ export class WebSocketTransport implements ConnectionTransport { return transport; } - constructor(ws: WebSocket, sessionId: string) { + constructor(ws: WebSocket, sessionId: string, { disableChunking = false } = {}) { this._pingInterval = setInterval(() => { return this._ws.send('ping'); }, 1000); // TODO more investigation this._ws = ws; this.sessionId = sessionId; + this._disableChunking = disableChunking; this._ws.addEventListener('message', event => { + if (this._disableChunking) { + if (event.data && this.onmessage) + this.onmessage!(JSON.parse(event.data as string) as ProtocolResponse); + return; + } + this._chunks.push(new Uint8Array(event.data as ArrayBuffer)); const message = chunksToMessage(this._chunks, sessionId); if (message && this.onmessage) @@ -48,6 +56,10 @@ export class WebSocketTransport implements ConnectionTransport { } send(message: ProtocolRequest): void { + if (this._disableChunking) { + this._ws.send(JSON.stringify(message)); + return; + } for (const chunk of messageToChunks(JSON.stringify(message))) this._ws.send(chunk); } diff --git a/packages/playwright-cloudflare/src/index.ts b/packages/playwright-cloudflare/src/index.ts index 51330adfe..b07e22161 100644 --- a/packages/playwright-cloudflare/src/index.ts +++ b/packages/playwright-cloudflare/src/index.ts @@ -36,6 +36,11 @@ const originalConnectOverCDP = playwright.chromium.connectOverCDP; const wsEndpoint = typeof endpointURLOrOptions === 'string' ? endpointURLOrOptions : endpointURLOrOptions.wsEndpoint ?? endpointURLOrOptions.endpointURL; if (!wsEndpoint) throw new Error('No wsEndpoint provided'); + + if (isExternalWebSocketEndpoint(wsEndpoint)) { + return connectToExternalWebSocket(wsEndpoint); + } + const wsUrl = new URL(wsEndpoint); // by default, playwright.chromium.connectOverCDP enforces persistent to true (the default behavior upstream) if (!wsUrl.searchParams.has('persistent')) @@ -45,6 +50,35 @@ const originalConnectOverCDP = playwright.chromium.connectOverCDP; : launch(wsUrl.toString()); }; +function extractSessionIdFromUrl(wsEndpoint: string): string | undefined { + const url = new URL(wsEndpoint); + const sessionId = url.searchParams.get('browser_session') ?? undefined; + return sessionId; +} + +async function connectToExternalWebSocket(wsEndpoint: string): Promise { + resetMonotonicTime(); + const webSocket = new WebSocket(wsEndpoint); + await new Promise((resolve, reject) => { + webSocket.addEventListener('open', () => { + resolve(webSocket); + }); + webSocket.addEventListener('error', (error) => { + reject(error); + }); + }); + + const sessionId = extractSessionIdFromUrl(wsEndpoint); + + const transport = new WebSocketTransport(webSocket, sessionId ?? '', { disableChunking: true }); + + return await createBrowser(transport, { persistent: true }); +} + +function isExternalWebSocketEndpoint(endpoint: string): boolean { + return endpoint.startsWith('ws://') || endpoint.startsWith('wss://'); +} + async function connectDevtools(endpoint: BrowserEndpoint, options: { sessionId: string, persistent?: boolean }): Promise { resetMonotonicTime(); const url = new URL(`${HTTP_FAKE_HOST}/v1/connectDevtools`);