diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index 526d99482e..89254e55a1 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -143,10 +143,11 @@ export class BrowserType extends ChannelOwner imple const deadline = params.timeout ? monotonicTime() + params.timeout : 0; let browser: Browser; const headers = { 'x-playwright-browser': this.name(), ...params.headers }; - const connectParams: channels.BrowserTypeConnectParams = { wsEndpoint, headers, slowMo: params.slowMo, timeout: params.timeout }; + const localUtils = this._connection.localUtils(); + const connectParams: channels.LocalUtilsConnectParams = { wsEndpoint, headers, slowMo: params.slowMo, timeout: params.timeout }; if ((params as any).__testHookRedirectPortForwarding) connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding; - const { pipe } = await this._channel.connect(connectParams); + const { pipe } = await localUtils._channel.connect(connectParams); const closePipe = () => pipe.close().catch(() => {}); const connection = new Connection(this._connection.localUtils()); connection.markAsRemote(); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index b332943ccc..2f7080237c 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -245,6 +245,16 @@ scheme.LocalUtilsHarUnzipParams = tObject({ harFile: tString, }); scheme.LocalUtilsHarUnzipResult = tOptional(tObject({})); +scheme.LocalUtilsConnectParams = tObject({ + wsEndpoint: tString, + headers: tOptional(tAny), + slowMo: tOptional(tNumber), + timeout: tOptional(tNumber), + socksProxyRedirectPortForTest: tOptional(tNumber), +}); +scheme.LocalUtilsConnectResult = tObject({ + pipe: tChannel(['JsonPipe']), +}); scheme.RootInitializer = tOptional(tObject({})); scheme.RootInitializeParams = tObject({ sdkLanguage: tString, @@ -414,16 +424,6 @@ scheme.BrowserTypeInitializer = tObject({ executablePath: tString, name: tString, }); -scheme.BrowserTypeConnectParams = tObject({ - wsEndpoint: tString, - headers: tOptional(tAny), - slowMo: tOptional(tNumber), - timeout: tOptional(tNumber), - socksProxyRedirectPortForTest: tOptional(tNumber), -}); -scheme.BrowserTypeConnectResult = tObject({ - pipe: tChannel(['JsonPipe']), -}); scheme.BrowserTypeLaunchParams = tObject({ channel: tOptional(tString), executablePath: tOptional(tString), diff --git a/packages/playwright-core/src/server/dispatchers/browserTypeDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserTypeDispatcher.ts index 16159accc9..bf7ccd518f 100644 --- a/packages/playwright-core/src/server/dispatchers/browserTypeDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserTypeDispatcher.ts @@ -21,17 +21,6 @@ import type { RootDispatcher } from './dispatcher'; import { Dispatcher } from './dispatcher'; import { BrowserContextDispatcher } from './browserContextDispatcher'; import type { CallMetadata } from '../instrumentation'; -import { JsonPipeDispatcher } from '../dispatchers/jsonPipeDispatcher'; -import { getUserAgent } from '../../common/userAgent'; -import * as socks from '../../common/socksProxy'; -import EventEmitter from 'events'; -import { ProgressController } from '../progress'; -import type { Progress } from '../progress'; -import { WebSocketTransport } from '../transport'; -import { findValidator, ValidationError, type ValidatorContext } from '../../protocol/validator'; -import { fetchData } from '../../common/netUtils'; -import type { HTTPRequestParams } from '../../common/netUtils'; -import type http from 'http'; export class BrowserTypeDispatcher extends Dispatcher implements channels.BrowserTypeChannel { _type_BrowserType = true; @@ -60,134 +49,4 @@ export class BrowserTypeDispatcher extends Dispatcher { - const controller = new ProgressController(metadata, this._object); - controller.setLogName('browser'); - return await controller.run(async progress => { - const paramsHeaders = Object.assign({ 'User-Agent': getUserAgent() }, params.headers || {}); - const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint); - - const transport = await WebSocketTransport.connect(progress, wsEndpoint, paramsHeaders, true); - let socksInterceptor: SocksInterceptor | undefined; - const pipe = new JsonPipeDispatcher(this); - transport.onmessage = json => { - if (json.method === '__create__' && json.params.type === 'SocksSupport') - socksInterceptor = new SocksInterceptor(transport, params.socksProxyRedirectPortForTest, json.params.guid); - if (socksInterceptor?.interceptMessage(json)) - return; - const cb = () => { - try { - pipe.dispatch(json); - } catch (e) { - transport.close(); - } - }; - if (params.slowMo) - setTimeout(cb, params.slowMo); - else - cb(); - }; - pipe.on('message', message => { - transport.send(message); - }); - transport.onclose = () => { - socksInterceptor?.cleanup(); - pipe.wasClosed(); - }; - pipe.on('close', () => transport.close()); - return { pipe }; - }, params.timeout || 0); - } -} - -class SocksInterceptor { - private _handler: socks.SocksProxyHandler; - private _channel: channels.SocksSupportChannel & EventEmitter; - private _socksSupportObjectGuid: string; - private _ids = new Set(); - - constructor(transport: WebSocketTransport, redirectPortForTest: number | undefined, socksSupportObjectGuid: string) { - this._handler = new socks.SocksProxyHandler(redirectPortForTest); - this._socksSupportObjectGuid = socksSupportObjectGuid; - - let lastId = -1; - this._channel = new Proxy(new EventEmitter(), { - get: (obj: any, prop) => { - if ((prop in obj) || obj[prop] !== undefined || typeof prop !== 'string') - return obj[prop]; - return (params: any) => { - try { - const id = --lastId; - this._ids.add(id); - const validator = findValidator('SocksSupport', prop, 'Params'); - params = validator(params, '', { tChannelImpl: tChannelForSocks, binary: 'toBase64' }); - transport.send({ id, guid: socksSupportObjectGuid, method: prop, params, metadata: { stack: [], apiName: '', internal: true } } as any); - } catch (e) { - } - }; - }, - }) as channels.SocksSupportChannel & EventEmitter; - this._handler.on(socks.SocksProxyHandler.Events.SocksConnected, (payload: socks.SocksSocketConnectedPayload) => this._channel.socksConnected(payload)); - this._handler.on(socks.SocksProxyHandler.Events.SocksData, (payload: socks.SocksSocketDataPayload) => this._channel.socksData(payload)); - this._handler.on(socks.SocksProxyHandler.Events.SocksError, (payload: socks.SocksSocketErrorPayload) => this._channel.socksError(payload)); - this._handler.on(socks.SocksProxyHandler.Events.SocksFailed, (payload: socks.SocksSocketFailedPayload) => this._channel.socksFailed(payload)); - this._handler.on(socks.SocksProxyHandler.Events.SocksEnd, (payload: socks.SocksSocketEndPayload) => this._channel.socksEnd(payload)); - this._channel.on('socksRequested', payload => this._handler.socketRequested(payload)); - this._channel.on('socksClosed', payload => this._handler.socketClosed(payload)); - this._channel.on('socksData', payload => this._handler.sendSocketData(payload)); - } - - cleanup() { - this._handler.cleanup(); - } - - interceptMessage(message: any): boolean { - if (this._ids.has(message.id)) { - this._ids.delete(message.id); - return true; - } - if (message.guid === this._socksSupportObjectGuid) { - const validator = findValidator('SocksSupport', message.method, 'Event'); - const params = validator(message.params, '', { tChannelImpl: tChannelForSocks, binary: 'fromBase64' }); - this._channel.emit(message.method, params); - return true; - } - return false; - } -} - -function tChannelForSocks(names: '*' | string[], arg: any, path: string, context: ValidatorContext) { - throw new ValidationError(`${path}: channels are not expected in SocksSupport`); -} - -async function urlToWSEndpoint(progress: Progress, endpointURL: string): Promise { - if (endpointURL.startsWith('ws')) - return endpointURL; - - progress.log(` retrieving websocket url from ${endpointURL}`); - const fetchUrl = new URL(endpointURL); - if (!fetchUrl.pathname.endsWith('/')) - fetchUrl.pathname += '/'; - fetchUrl.pathname += 'json'; - const json = await fetchData({ - url: fetchUrl.toString(), - method: 'GET', - timeout: progress.timeUntilDeadline(), - headers: { 'User-Agent': getUserAgent() }, - }, async (params: HTTPRequestParams, response: http.IncomingMessage) => { - return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` + - `This does not look like a Playwright server, try connecting via ws://.`); - }); - progress.throwIfAborted(); - - const wsUrl = new URL(endpointURL); - let wsEndpointPath = JSON.parse(json).wsEndpointPath; - if (wsEndpointPath.startsWith('/')) - wsEndpointPath = wsEndpointPath.substring(1); - if (!wsUrl.pathname.endsWith('/')) - wsUrl.pathname += '/'; - wsUrl.pathname += wsEndpointPath; - wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - return wsUrl.toString(); } diff --git a/packages/playwright-core/src/server/dispatchers/jsonPipeDispatcher.ts b/packages/playwright-core/src/server/dispatchers/jsonPipeDispatcher.ts index 03fceb366d..914879adc3 100644 --- a/packages/playwright-core/src/server/dispatchers/jsonPipeDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/jsonPipeDispatcher.ts @@ -18,11 +18,11 @@ import type * as channels from '@protocol/channels'; import { Dispatcher } from './dispatcher'; import { createGuid } from '../../utils'; import { serializeError } from '../../protocol/serializers'; -import type { BrowserTypeDispatcher } from './browserTypeDispatcher'; +import type { LocalUtilsDispatcher } from './localUtilsDispatcher'; -export class JsonPipeDispatcher extends Dispatcher<{ guid: string }, channels.JsonPipeChannel, BrowserTypeDispatcher> implements channels.JsonPipeChannel { +export class JsonPipeDispatcher extends Dispatcher<{ guid: string }, channels.JsonPipeChannel, LocalUtilsDispatcher> implements channels.JsonPipeChannel { _type_JsonPipe = true; - constructor(scope: BrowserTypeDispatcher) { + constructor(scope: LocalUtilsDispatcher) { super(scope, { guid: 'jsonPipe@' + createGuid() }, 'JsonPipe', {}); } diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index 3df4a864a3..26e99fc516 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type EventEmitter from 'events'; +import EventEmitter from 'events'; import fs from 'fs'; import path from 'path'; import type * as channels from '@protocol/channels'; @@ -26,17 +26,32 @@ import { yazl, yauzl } from '../../zipBundle'; import { ZipFile } from '../../utils/zipFile'; import type * as har from '@trace/har'; import type { HeadersArray } from '../types'; +import { JsonPipeDispatcher } from '../dispatchers/jsonPipeDispatcher'; +import * as socks from '../../common/socksProxy'; +import { WebSocketTransport } from '../transport'; +import type { CallMetadata } from '../instrumentation'; +import { getUserAgent } from '../../common/userAgent'; +import type { Progress } from '../progress'; +import { ProgressController } from '../progress'; +import { findValidator, ValidationError } from '../../protocol/validator'; +import type { ValidatorContext } from '../../protocol/validator'; +import { fetchData } from '../../common/netUtils'; +import type { HTTPRequestParams } from '../../common/netUtils'; +import type http from 'http'; +import type { Playwright } from '../playwright'; +import { SdkObject } from '../../server/instrumentation'; export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel { _type_LocalUtils: boolean; private _harBakends = new Map(); - constructor(scope: RootDispatcher) { - super(scope, { guid: 'localUtils@' + createGuid() }, 'LocalUtils', {}); + constructor(scope: RootDispatcher, playwright: Playwright) { + const localUtils = new SdkObject(playwright, 'localUtils', 'localUtils'); + super(scope, localUtils, 'LocalUtils', {}); this._type_LocalUtils = true; } - async zip(params: channels.LocalUtilsZipParams, metadata?: channels.Metadata): Promise { + async zip(params: channels.LocalUtilsZipParams, metadata: CallMetadata): Promise { const promise = new ManualPromise(); const zipFile = new yazl.ZipFile(); (zipFile as any as EventEmitter).on('error', error => promise.reject(error)); @@ -91,7 +106,7 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. return promise; } - async harOpen(params: channels.LocalUtilsHarOpenParams, metadata?: channels.Metadata): Promise { + async harOpen(params: channels.LocalUtilsHarOpenParams, metadata: CallMetadata): Promise { let harBackend: HarBackend; if (params.file.endsWith('.zip')) { const zipFile = new ZipFile(params.file); @@ -110,14 +125,14 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. return { harId: harBackend.id }; } - async harLookup(params: channels.LocalUtilsHarLookupParams, metadata?: channels.Metadata): Promise { + async harLookup(params: channels.LocalUtilsHarLookupParams, metadata: CallMetadata): Promise { const harBackend = this._harBakends.get(params.harId); if (!harBackend) return { action: 'error', message: `Internal error: har was not opened` }; return await harBackend.lookup(params.url, params.method, params.headers, params.postData, params.isNavigationRequest); } - async harClose(params: channels.LocalUtilsHarCloseParams, metadata?: channels.Metadata): Promise { + async harClose(params: channels.LocalUtilsHarCloseParams, metadata: CallMetadata): Promise { const harBackend = this._harBakends.get(params.harId); if (harBackend) { this._harBakends.delete(harBackend.id); @@ -125,7 +140,7 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. } } - async harUnzip(params: channels.LocalUtilsHarUnzipParams, metadata?: channels.Metadata): Promise { + async harUnzip(params: channels.LocalUtilsHarUnzipParams, metadata: CallMetadata): Promise { const dir = path.dirname(params.zipFile); const zipFile = new ZipFile(params.zipFile); for (const entry of await zipFile.entries()) { @@ -138,6 +153,46 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. zipFile.close(); await fs.promises.unlink(params.zipFile); } + + async connect(params: channels.LocalUtilsConnectParams, metadata: CallMetadata): Promise { + const controller = new ProgressController(metadata, this._object as SdkObject); + controller.setLogName('browser'); + return await controller.run(async progress => { + const paramsHeaders = Object.assign({ 'User-Agent': getUserAgent() }, params.headers || {}); + const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint); + + const transport = await WebSocketTransport.connect(progress, wsEndpoint, paramsHeaders, true); + let socksInterceptor: SocksInterceptor | undefined; + const pipe = new JsonPipeDispatcher(this); + transport.onmessage = json => { + if (json.method === '__create__' && json.params.type === 'SocksSupport') + socksInterceptor = new SocksInterceptor(transport, params.socksProxyRedirectPortForTest, json.params.guid); + if (socksInterceptor?.interceptMessage(json)) + return; + const cb = () => { + try { + pipe.dispatch(json); + } catch (e) { + transport.close(); + } + }; + if (params.slowMo) + setTimeout(cb, params.slowMo); + else + cb(); + }; + pipe.on('message', message => { + transport.send(message); + }); + transport.onclose = () => { + socksInterceptor?.cleanup(); + pipe.wasClosed(); + }; + pipe.on('close', () => transport.close()); + return { pipe }; + }, params.timeout || 0); + } + } const redirectStatus = [301, 302, 303, 307, 308]; @@ -262,6 +317,62 @@ class HarBackend { } } +class SocksInterceptor { + private _handler: socks.SocksProxyHandler; + private _channel: channels.SocksSupportChannel & EventEmitter; + private _socksSupportObjectGuid: string; + private _ids = new Set(); + + constructor(transport: WebSocketTransport, redirectPortForTest: number | undefined, socksSupportObjectGuid: string) { + this._handler = new socks.SocksProxyHandler(redirectPortForTest); + this._socksSupportObjectGuid = socksSupportObjectGuid; + + let lastId = -1; + this._channel = new Proxy(new EventEmitter(), { + get: (obj: any, prop) => { + if ((prop in obj) || obj[prop] !== undefined || typeof prop !== 'string') + return obj[prop]; + return (params: any) => { + try { + const id = --lastId; + this._ids.add(id); + const validator = findValidator('SocksSupport', prop, 'Params'); + params = validator(params, '', { tChannelImpl: tChannelForSocks, binary: 'toBase64' }); + transport.send({ id, guid: socksSupportObjectGuid, method: prop, params, metadata: { stack: [], apiName: '', internal: true } } as any); + } catch (e) { + } + }; + }, + }) as channels.SocksSupportChannel & EventEmitter; + this._handler.on(socks.SocksProxyHandler.Events.SocksConnected, (payload: socks.SocksSocketConnectedPayload) => this._channel.socksConnected(payload)); + this._handler.on(socks.SocksProxyHandler.Events.SocksData, (payload: socks.SocksSocketDataPayload) => this._channel.socksData(payload)); + this._handler.on(socks.SocksProxyHandler.Events.SocksError, (payload: socks.SocksSocketErrorPayload) => this._channel.socksError(payload)); + this._handler.on(socks.SocksProxyHandler.Events.SocksFailed, (payload: socks.SocksSocketFailedPayload) => this._channel.socksFailed(payload)); + this._handler.on(socks.SocksProxyHandler.Events.SocksEnd, (payload: socks.SocksSocketEndPayload) => this._channel.socksEnd(payload)); + this._channel.on('socksRequested', payload => this._handler.socketRequested(payload)); + this._channel.on('socksClosed', payload => this._handler.socketClosed(payload)); + this._channel.on('socksData', payload => this._handler.sendSocketData(payload)); + } + + cleanup() { + this._handler.cleanup(); + } + + interceptMessage(message: any): boolean { + if (this._ids.has(message.id)) { + this._ids.delete(message.id); + return true; + } + if (message.guid === this._socksSupportObjectGuid) { + const validator = findValidator('SocksSupport', message.method, 'Event'); + const params = validator(message.params, '', { tChannelImpl: tChannelForSocks, binary: 'fromBase64' }); + this._channel.emit(message.method, params); + return true; + } + return false; + } +} + function countMatchingHeaders(harHeaders: har.Header[], headers: HeadersArray): number { const set = new Set(headers.map(h => h.name.toLowerCase() + ':' + h.value)); let matches = 0; @@ -272,3 +383,37 @@ function countMatchingHeaders(harHeaders: har.Header[], headers: HeadersArray): return matches; } +function tChannelForSocks(names: '*' | string[], arg: any, path: string, context: ValidatorContext) { + throw new ValidationError(`${path}: channels are not expected in SocksSupport`); +} + +async function urlToWSEndpoint(progress: Progress, endpointURL: string): Promise { + if (endpointURL.startsWith('ws')) + return endpointURL; + + progress.log(` retrieving websocket url from ${endpointURL}`); + const fetchUrl = new URL(endpointURL); + if (!fetchUrl.pathname.endsWith('/')) + fetchUrl.pathname += '/'; + fetchUrl.pathname += 'json'; + const json = await fetchData({ + url: fetchUrl.toString(), + method: 'GET', + timeout: progress.timeUntilDeadline(), + headers: { 'User-Agent': getUserAgent() }, + }, async (params: HTTPRequestParams, response: http.IncomingMessage) => { + return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` + + `This does not look like a Playwright server, try connecting via ws://.`); + }); + progress.throwIfAborted(); + + const wsUrl = new URL(endpointURL); + let wsEndpointPath = JSON.parse(json).wsEndpointPath; + if (wsEndpointPath.startsWith('/')) + wsEndpointPath = wsEndpointPath.substring(1); + if (!wsUrl.pathname.endsWith('/')) + wsUrl.pathname += '/'; + wsUrl.pathname += wsEndpointPath; + wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'; + return wsUrl.toString(); +} diff --git a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts index 75dc2920b1..b04ecf909a 100644 --- a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts @@ -47,7 +47,7 @@ export class PlaywrightDispatcher extends Dispatcher; harClose(params: LocalUtilsHarCloseParams, metadata?: Metadata): Promise; harUnzip(params: LocalUtilsHarUnzipParams, metadata?: Metadata): Promise; + connect(params: LocalUtilsConnectParams, metadata?: Metadata): Promise; } export type LocalUtilsZipParams = { zipFile: string, @@ -442,6 +443,22 @@ export type LocalUtilsHarUnzipOptions = { }; export type LocalUtilsHarUnzipResult = void; +export type LocalUtilsConnectParams = { + wsEndpoint: string, + headers?: any, + slowMo?: number, + timeout?: number, + socksProxyRedirectPortForTest?: number, +}; +export type LocalUtilsConnectOptions = { + headers?: any, + slowMo?: number, + timeout?: number, + socksProxyRedirectPortForTest?: number, +}; +export type LocalUtilsConnectResult = { + pipe: JsonPipeChannel, +}; export interface LocalUtilsEvents { } @@ -765,27 +782,10 @@ export interface BrowserTypeEventTarget { } export interface BrowserTypeChannel extends BrowserTypeEventTarget, Channel { _type_BrowserType: boolean; - connect(params: BrowserTypeConnectParams, metadata?: Metadata): Promise; launch(params: BrowserTypeLaunchParams, metadata?: Metadata): Promise; launchPersistentContext(params: BrowserTypeLaunchPersistentContextParams, metadata?: Metadata): Promise; connectOverCDP(params: BrowserTypeConnectOverCDPParams, metadata?: Metadata): Promise; } -export type BrowserTypeConnectParams = { - wsEndpoint: string, - headers?: any, - slowMo?: number, - timeout?: number, - socksProxyRedirectPortForTest?: number, -}; -export type BrowserTypeConnectOptions = { - headers?: any, - slowMo?: number, - timeout?: number, - socksProxyRedirectPortForTest?: number, -}; -export type BrowserTypeConnectResult = { - pipe: JsonPipeChannel, -}; export type BrowserTypeLaunchParams = { channel?: string, executablePath?: string, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 124a138f0b..4996ce243a 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -528,6 +528,16 @@ LocalUtils: zipFile: string harFile: string + connect: + parameters: + wsEndpoint: string + headers: json? + slowMo: number? + timeout: number? + socksProxyRedirectPortForTest: number? + returns: + pipe: JsonPipe + Root: type: interface @@ -772,16 +782,6 @@ BrowserType: commands: - connect: - parameters: - wsEndpoint: string - headers: json? - slowMo: number? - timeout: number? - socksProxyRedirectPortForTest: number? - returns: - pipe: JsonPipe - launch: parameters: $mixin: LaunchOptions