Skip to content

Commit f14741d

Browse files
authored
feat(webkit): support connectOverCDP(transport) for direct WVPage attach (#41038)
1 parent 17c003c commit f14741d

11 files changed

Lines changed: 118 additions & 50 deletions

File tree

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@
106106
"@types/react-dom": "^19.2.1",
107107
"@types/retry": "^0.12.5",
108108
"@types/source-map-support": "^0.5.4",
109-
"@types/ws": "8.2.2",
109+
"@types/ws": "8.18.1",
110110
"@types/xml2js": "^0.4.9",
111111
"@types/yazl": "^2.4.2",
112112
"@typescript-eslint/eslint-plugin": "^8.59.0",

packages/playwright-client/types/types.d.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15384,10 +15384,6 @@ export interface BrowserType<Unused = {}> {
1538415384
* @param options
1538515385
*/
1538615386
connectOverCDP(endpointURL: string, options?: ConnectOverCDPOptions): Promise<Browser>;
15387-
/**
15388-
* Option `wsEndpoint` is deprecated. Instead use `endpointURL`.
15389-
* @deprecated
15390-
*/
1539115387
/**
1539215388
* This method attaches Playwright to an existing browser instance using the Chrome DevTools Protocol.
1539315389
*
@@ -15413,7 +15409,11 @@ export interface BrowserType<Unused = {}> {
1541315409
* `ws://127.0.0.1:9222/devtools/browser/387adf4c-243f-4051-a181-46798f4a46f4`.
1541415410
* @param options
1541515411
*/
15416-
connectOverCDP(options: ConnectOverCDPOptions & { wsEndpoint?: string }): Promise<Browser>;
15412+
connectOverCDP(transport: ConnectOverCDPTransport, options?: ConnectOverCDPOptions): Promise<Browser>;
15413+
/**
15414+
* Option `wsEndpoint` is deprecated. Instead use `endpointURL`.
15415+
* @deprecated
15416+
*/
1541715417
/**
1541815418
* This method attaches Playwright to an existing browser instance using the Chrome DevTools Protocol.
1541915419
*
@@ -15439,7 +15439,7 @@ export interface BrowserType<Unused = {}> {
1543915439
* `ws://127.0.0.1:9222/devtools/browser/387adf4c-243f-4051-a181-46798f4a46f4`.
1544015440
* @param options
1544115441
*/
15442-
connectOverCDP(transport: ConnectionTransport, options?: ConnectOverCDPOptions): Promise<Browser>;
15442+
connectOverCDP(options: ConnectOverCDPOptions & { wsEndpoint?: string }): Promise<Browser>;
1544315443

1544415444
/**
1544515445
* This method attaches Playwright to an existing browser instance created via `BrowserType.launchServer` in Node.js.
@@ -16221,7 +16221,8 @@ export interface BrowserType<Unused = {}> {
1622116221
name(): string;
1622216222
}
1622316223

16224-
export interface ConnectionTransport {
16224+
export interface ConnectOverCDPTransport {
16225+
open?(): void;
1622516226
send(message: object): void;
1622616227
close(): void;
1622716228
onmessage?: (message: object) => void;

packages/playwright-core/src/client/browserType.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,10 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
138138

139139
async connectOverCDP(options: api.ConnectOverCDPOptions & { wsEndpoint?: string }): Promise<api.Browser>;
140140
async connectOverCDP(endpointURL: string, options?: api.ConnectOverCDPOptions): Promise<api.Browser>;
141-
async connectOverCDP(transport: api.ConnectionTransport, options?: api.ConnectOverCDPOptions): Promise<api.Browser>;
142-
async connectOverCDP(overloaded: (api.ConnectOverCDPOptions & { wsEndpoint?: string }) | string | api.ConnectionTransport, options?: api.ConnectOverCDPOptions): Promise<Browser> {
141+
async connectOverCDP(transport: api.ConnectOverCDPTransport, options?: api.ConnectOverCDPOptions): Promise<api.Browser>;
142+
async connectOverCDP(overloaded: (api.ConnectOverCDPOptions & { wsEndpoint?: string }) | string | api.ConnectOverCDPTransport, options?: api.ConnectOverCDPOptions): Promise<Browser> {
143143
let endpointURL: string | undefined;
144-
let transport: api.ConnectionTransport | undefined;
144+
let transport: api.ConnectOverCDPTransport | undefined;
145145
let params: api.ConnectOverCDPOptions;
146146
if (typeof overloaded === 'string') {
147147
endpointURL = overloaded;
@@ -193,6 +193,6 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
193193
}
194194
}
195195

196-
function isConnectionTransport(value: any): value is api.ConnectionTransport {
196+
function isConnectionTransport(value: any): value is api.ConnectOverCDPTransport {
197197
return !!value && typeof value === 'object' && typeof value.send === 'function' && typeof value.close === 'function';
198198
}

packages/playwright-core/src/server/webkit/DEPS.list

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
../registry/
66
node_modules/jpeg-js
77
node_modules/pngjs
8+
node_modules/ws
89

910
[webkit.ts]
1011
./webview/wvBrowser.ts

packages/playwright-core/src/server/webkit/webkit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export class WebKit extends BrowserType {
4242
}
4343

4444
override async connectOverCDP(progress: Progress, params: channels.BrowserTypeConnectOverCDPParams): Promise<Browser> {
45-
return connectOverRDP(progress, this, params.endpointURL!, params);
45+
return connectOverRDP(progress, this, params);
4646
}
4747

4848
override amendEnvironment(env: NodeJS.ProcessEnv, userDataDir: string, isPersistent: boolean, options: types.LaunchOptions): NodeJS.ProcessEnv {

packages/playwright-core/src/server/webkit/webview/wvBrowser.ts

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@
1818
import os from 'os';
1919
import path from 'path';
2020

21+
import ws from 'ws';
2122
import { debugLogger, RecentLogsCollector } from '@utils/debugLogger';
2223
import { removeFolders } from '@utils/fileUtils';
24+
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '@utils/happyEyeballs';
2325
import { headersArrayToObject } from '@isomorphic/headers';
2426
import { Browser } from '../../browser';
2527
import { helper } from '../../helper';
26-
import { WebSocketTransport } from '../../transport';
28+
import { perMessageDeflate } from '../../transport';
2729
import { getUserAgent } from '../../userAgent';
2830
import { BrowserContext } from '../../browserContext';
2931
import { DialogBridge } from './dialogBridge';
@@ -33,9 +35,11 @@ import { WVPage } from './wvPage';
3335
import type { BrowserOptions, BrowserProcess } from '../../browser';
3436
import type { SdkObject } from '../../instrumentation';
3537
import type { InitScript, Page } from '../../page';
38+
import type { ProtocolRequest, ProtocolResponse } from '../../transport';
3639
import type * as types from '../../types';
3740
import type * as channels from '@protocol/channels';
3841
import type { Progress } from '../../progress';
42+
import type { ConnectOverCDPTransport } from '../../../../types/types.d.ts';
3943

4044
type ProxyTab = {
4145
url: string;
@@ -64,26 +68,82 @@ async function listTabs(proxyBase: string, headers: { [key: string]: string }):
6468
return data.filter(t => !!t.webSocketDebuggerUrl);
6569
}
6670

67-
export async function connectOverRDP(progress: Progress, parent: SdkObject, endpointURL: string, options: { slowMo?: number, headers?: types.HeadersArray, isLocal?: boolean, noDefaults?: boolean, artifactsDir?: string }): Promise<Browser> {
71+
// Local WebSocket-backed transport: defers opening the socket until `open()`
72+
// is called, so listeners on `onmessage` are wired before the remote side
73+
// starts emitting events.
74+
class DeferredWebSocketTransport implements ConnectOverCDPTransport {
75+
private readonly _url: string;
76+
private readonly _headers: { [key: string]: string };
77+
private _ws: ws | undefined;
78+
private _closed = false;
79+
80+
onmessage?: (message: object) => void;
81+
onclose?: (reason?: string) => void;
82+
83+
constructor(url: string, headers: { [key: string]: string }) {
84+
this._url = url;
85+
this._headers = headers;
86+
}
87+
88+
open(): void {
89+
if (this._closed)
90+
return;
91+
const url = this._url;
92+
this._ws = new ws(url, [], {
93+
maxPayload: 256 * 1024 * 1024,
94+
headers: this._headers,
95+
followRedirects: true,
96+
agent: (/^(https|wss):\/\//.test(url)) ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent,
97+
perMessageDeflate,
98+
allowSynchronousEvents: false,
99+
});
100+
this._ws.addEventListener('message', event => {
101+
const eventData = event.data as string;
102+
let parsedJson: ProtocolResponse;
103+
try {
104+
parsedJson = JSON.parse(eventData);
105+
this.onmessage?.(parsedJson);
106+
} catch {
107+
this._ws?.close();
108+
}
109+
});
110+
this._ws.addEventListener('close', event => {
111+
this.onclose?.(event.reason);
112+
});
113+
this._ws.addEventListener('error', () => {});
114+
}
115+
116+
send(message: object): void {
117+
this._ws?.send(JSON.stringify(message as ProtocolRequest));
118+
}
119+
120+
close(): void {
121+
this._closed = true;
122+
this._ws?.close();
123+
}
124+
}
125+
126+
export async function connectOverRDP(progress: Progress, parent: SdkObject, params: channels.BrowserTypeConnectOverCDPParams): Promise<Browser> {
68127
let headersMap: { [key: string]: string; } | undefined;
69-
if (options.headers)
70-
headersMap = headersArrayToObject(options.headers, false);
128+
if (params.headers)
129+
headersMap = headersArrayToObject(params.headers, false);
71130
if (!headersMap)
72131
headersMap = { 'User-Agent': getUserAgent() };
73132
else if (!Object.keys(headersMap).some(key => key.toLowerCase() === 'user-agent'))
74133
headersMap['User-Agent'] = getUserAgent();
75134

76-
const proxyBase = deriveProxyBase(endpointURL);
135+
const transport = params.transport as ConnectOverCDPTransport | undefined;
136+
const proxyBase = transport ? '' : deriveProxyBase(params.endpointURL!);
77137

78-
const artifactsDir = options.artifactsDir ?? path.join(os.tmpdir(), 'playwright-artifacts-');
138+
const artifactsDir = params.artifactsDir ?? path.join(os.tmpdir(), 'playwright-artifacts-');
79139
const doCleanup = async () => {
80140
await removeFolders([artifactsDir]);
81141
};
82142

83143
const browser = await progress.race((async () => {
84144
const dialogBridge = await DialogBridge.start();
85-
const created = new WVBrowser(parent, proxyBase, headersMap!, dialogBridge, {
86-
slowMo: options.slowMo,
145+
const created = new WVBrowser(parent, proxyBase, headersMap!, dialogBridge, transport, {
146+
slowMo: params.slowMo,
87147
name: 'webkit',
88148
browserType: 'webkit',
89149
browserProcess: { close: async () => {}, kill: async () => {} } as BrowserProcess,
@@ -104,15 +164,15 @@ export async function connectOverRDP(progress: Progress, parent: SdkObject, endp
104164
return created;
105165
})());
106166

107-
if (!options.isLocal)
167+
if (!params.isLocal)
108168
browser._isCollocatedWithServer = false;
109169
browser.on(Browser.Events.Disconnected, doCleanup);
110170
return browser;
111171
}
112172

113173
type TabEntry = {
114174
pageId: string;
115-
transport: WebSocketTransport;
175+
transport: ConnectOverCDPTransport;
116176
connection: WVConnection;
117177
page: WVPage;
118178
};
@@ -122,24 +182,30 @@ export class WVBrowser extends Browser {
122182
readonly _proxyBase: string;
123183
readonly _headers: { [key: string]: string };
124184
readonly _dialogBridge: DialogBridge;
185+
readonly _directPageTransport: ConnectOverCDPTransport | undefined;
125186
readonly _tabs = new Map<string, TabEntry>();
126187
private _didCloseFired = false;
127188
// Backwards compat — old code still reads `_page` for the "primary" tab.
128189
_page!: WVPage;
129190

130-
constructor(parent: SdkObject, proxyBase: string, headers: { [key: string]: string }, dialogBridge: DialogBridge, options: BrowserOptions) {
191+
constructor(parent: SdkObject, proxyBase: string, headers: { [key: string]: string }, dialogBridge: DialogBridge, directPageTransport: ConnectOverCDPTransport | undefined, options: BrowserOptions) {
131192
super(parent, options);
132193
this._proxyBase = proxyBase;
133194
this._headers = headers;
134195
this._dialogBridge = dialogBridge;
196+
this._directPageTransport = directPageTransport;
135197
this._context = new WVBrowserContext(this);
136198
}
137199

138200
async _initialize(): Promise<void> {
139201
await this._context.initialize();
140-
await this._syncTabs();
141-
if (!this._tabs.size)
142-
throw new Error(`No Mobile Safari tabs found at ${this._proxyBase}/json — open Safari first.`);
202+
if (this._directPageTransport) {
203+
await this._attachTab('rdp-transport', this._directPageTransport);
204+
} else {
205+
await this._syncTabs();
206+
if (!this._tabs.size)
207+
throw new Error(`No Mobile Safari tabs found at ${this._proxyBase}/json — open Safari first.`);
208+
}
143209
this._page = this._firstTab().page;
144210
}
145211

@@ -156,7 +222,7 @@ export class WVBrowser extends Browser {
156222
if (this._tabs.has(pageId))
157223
continue;
158224
try {
159-
await this._attachTab(pageId, tab);
225+
await this._attachTab(pageId, new DeferredWebSocketTransport(tab.webSocketDebuggerUrl, this._headers));
160226
} catch (e) {
161227
debugLogger.log('error', `webview: failed to attach to tab ${pageId}: ${(e as Error).message}`);
162228
}
@@ -168,15 +234,14 @@ export class WVBrowser extends Browser {
168234
}
169235
}
170236

171-
private async _attachTab(pageId: string, tab: ProxyTab): Promise<void> {
172-
const transport = await WebSocketTransport.connect(undefined, tab.webSocketDebuggerUrl, { headers: this._headers, followRedirects: true });
237+
private async _attachTab(pageId: string, transport: ConnectOverCDPTransport): Promise<void> {
173238
const connection = new WVConnection(transport, () => this._detachTab(pageId), this.options.protocolLogger, this.options.browserLogsCollector);
174-
// TODO: handle this as RDP connection parameter.
175-
connection.outerSession.sendMayFail('Target.setPauseOnStart', { pauseOnStart: true });
176239
const dialogEndpoint = this._dialogBridge.endpointFor(pageId);
177240
const page = new WVPage(this._context, connection.outerSession, dialogEndpoint);
178241
this._dialogBridge.registerTab(pageId, req => page.onBridgeDialog(req));
179242
this._tabs.set(pageId, { pageId, transport, connection, page });
243+
transport.open?.();
244+
connection.outerSession.sendMayFail('Target.setPauseOnStart', { pauseOnStart: true });
180245
await page.waitForInitialized();
181246
}
182247

packages/playwright-core/src/server/webkit/webview/wvPage.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { PNG } from 'pngjs';
1919
import jpegjs from 'jpeg-js';
2020
import { assert } from '@isomorphic/assert';
2121
import { headersArrayToObject } from '@isomorphic/headers';
22+
import { ManualPromise } from '@isomorphic/manualPromise';
2223
import { splitErrorMessage } from '@isomorphic/stackTrace';
2324
import { debugLogger } from '@utils/debugLogger';
2425
import { eventsHelper } from '@utils/eventsHelper';
@@ -65,8 +66,7 @@ export class WVPage implements PageDelegate {
6566
private _firstNonInitialNavigationCommittedPromise: Promise<void>;
6667
private _firstNonInitialNavigationCommittedFulfill = () => {};
6768
_firstNonInitialNavigationCommittedReject = (e: Error) => {};
68-
private _initializedPromise: Promise<void>;
69-
private _initializedFulfill = () => {};
69+
private _initializedPromise = new ManualPromise<void>();
7070
private _lastConsoleMessage: { derivedType: string, text: string, handles: JSHandle[]; count: number, location: types.ConsoleMessageLocation; } | null = null;
7171
private readonly _requestIdToResponseReceivedPayloadEvent = new Map<string, Protocol.Network.responseReceivedPayload>();
7272

@@ -95,7 +95,6 @@ export class WVPage implements PageDelegate {
9595
});
9696
// Avoid unhandled rejection on disconnect in the middle of initialization.
9797
this._firstNonInitialNavigationCommittedPromise.catch(() => {});
98-
this._initializedPromise = new Promise(f => { this._initializedFulfill = f; });
9998
}
10099

101100
waitForInitialized(): Promise<void> {
@@ -225,7 +224,7 @@ export class WVPage implements PageDelegate {
225224
if (targetInfo.isPaused)
226225
this._outerSession.sendMayFail('Target.resume', { targetId: targetInfo.targetId });
227226
await this._page.reportAsNew(undefined, pageOrError instanceof Page ? undefined : pageOrError);
228-
this._initializedFulfill();
227+
this._initializedPromise.resolve();
229228
} else {
230229
assert(!this._provisionalPage);
231230
this._provisionalPage = new WVProvisionalPage(session, this);

packages/playwright-core/types/types.d.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15384,10 +15384,6 @@ export interface BrowserType<Unused = {}> {
1538415384
* @param options
1538515385
*/
1538615386
connectOverCDP(endpointURL: string, options?: ConnectOverCDPOptions): Promise<Browser>;
15387-
/**
15388-
* Option `wsEndpoint` is deprecated. Instead use `endpointURL`.
15389-
* @deprecated
15390-
*/
1539115387
/**
1539215388
* This method attaches Playwright to an existing browser instance using the Chrome DevTools Protocol.
1539315389
*
@@ -15413,7 +15409,11 @@ export interface BrowserType<Unused = {}> {
1541315409
* `ws://127.0.0.1:9222/devtools/browser/387adf4c-243f-4051-a181-46798f4a46f4`.
1541415410
* @param options
1541515411
*/
15416-
connectOverCDP(options: ConnectOverCDPOptions & { wsEndpoint?: string }): Promise<Browser>;
15412+
connectOverCDP(transport: ConnectOverCDPTransport, options?: ConnectOverCDPOptions): Promise<Browser>;
15413+
/**
15414+
* Option `wsEndpoint` is deprecated. Instead use `endpointURL`.
15415+
* @deprecated
15416+
*/
1541715417
/**
1541815418
* This method attaches Playwright to an existing browser instance using the Chrome DevTools Protocol.
1541915419
*
@@ -15439,7 +15439,7 @@ export interface BrowserType<Unused = {}> {
1543915439
* `ws://127.0.0.1:9222/devtools/browser/387adf4c-243f-4051-a181-46798f4a46f4`.
1544015440
* @param options
1544115441
*/
15442-
connectOverCDP(transport: ConnectionTransport, options?: ConnectOverCDPOptions): Promise<Browser>;
15442+
connectOverCDP(options: ConnectOverCDPOptions & { wsEndpoint?: string }): Promise<Browser>;
1544315443

1544415444
/**
1544515445
* This method attaches Playwright to an existing browser instance created via `BrowserType.launchServer` in Node.js.
@@ -16221,7 +16221,8 @@ export interface BrowserType<Unused = {}> {
1622116221
name(): string;
1622216222
}
1622316223

16224-
export interface ConnectionTransport {
16224+
export interface ConnectOverCDPTransport {
16225+
open?(): void;
1622516226
send(message: object): void;
1622616227
close(): void;
1622716228
onmessage?: (message: object) => void;

utils/generate_types/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ class TypesGenerator {
522522
...assertionClasses,
523523
]),
524524
ignoreMissing: new Set([
525-
'ConnectionTransport',
525+
'ConnectOverCDPTransport',
526526
]),
527527
});
528528
let types = await generator.generateTypes(path.join(__dirname, 'overrides.d.ts'));

0 commit comments

Comments
 (0)