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
20 changes: 20 additions & 0 deletions docs/src/api/class-browsertype.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ Some common examples:
1. `"<loopback>"` to expose localhost network.
1. `"*.test.internal-domain,*.staging.internal-domain,<loopback>"` to expose test/staging deployments and localhost.

### option: BrowserType.connect.proxy
* since: v1.52
- `proxy` <[Object]>
- `server` <[string]> Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy.
- `bypass` ?<[string]> Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
- `username` ?<[string]> Optional username to use if HTTP proxy requires authentication.
- `password` ?<[string]> Optional password to use if HTTP proxy requires authentication.

Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used by the browser to load web pages.

## async method: BrowserType.connectOverCDP
* since: v1.9
- returns: <[Browser]>
Expand Down Expand Up @@ -232,6 +242,16 @@ Logger sink for Playwright logging. Optional.
Maximum time in milliseconds to wait for the connection to be established. Defaults to
`30000` (30 seconds). Pass `0` to disable timeout.

### option: BrowserType.connectOverCDP.proxy
* since: v1.52
- `proxy` <[Object]>
- `server` <[string]> Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy.
- `bypass` ?<[string]> Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
- `username` ?<[string]> Optional username to use if HTTP proxy requires authentication.
- `password` ?<[string]> Optional password to use if HTTP proxy requires authentication.

Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used by the browser to load web pages.

## method: BrowserType.executablePath
* since: v1.8
- returns: <[string]>
Expand Down
6 changes: 2 additions & 4 deletions docs/src/api/params.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,8 @@ Dangerous option; use with care. Defaults to `false`.
## browser-option-proxy
- `proxy` <[Object]>
- `server` <[string]> Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example
`http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP
proxy.
- `bypass` ?<[string]> Optional comma-separated domains to bypass proxy, for example `".com, chromium.org,
.domain.com"`.
`http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy.
- `bypass` ?<[string]> Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
- `username` ?<[string]> Optional username to use if HTTP proxy requires authentication.
- `password` ?<[string]> Optional password to use if HTTP proxy requires authentication.

Expand Down
56 changes: 56 additions & 0 deletions packages/playwright-client/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21793,6 +21793,34 @@ export interface ConnectOverCDPOptions {
*/
logger?: Logger;

/**
* Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used
* by the browser to load web pages.
*/
proxy?: {
/**
* Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example
* `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP
* proxy.
*/
server: string;

/**
* Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
*/
bypass?: string;

/**
* Optional username to use if HTTP proxy requires authentication.
*/
username?: string;

/**
* Optional password to use if HTTP proxy requires authentication.
*/
password?: string;
};

/**
* Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going
* on. Defaults to 0.
Expand Down Expand Up @@ -21834,6 +21862,34 @@ export interface ConnectOptions {
*/
logger?: Logger;

/**
* Proxy settings to use for the connection between the client and the remote browser. Note this proxy **is not** used
* by the browser to load web pages.
*/
proxy?: {
/**
* Proxy to be used for the remote connection. HTTP and SOCKS proxies are supported, for example
* `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP
* proxy.
*/
server: string;

/**
* Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`.
*/
bypass?: string;

/**
* Optional username to use if HTTP proxy requires authentication.
*/
username?: string;

/**
* Optional password to use if HTTP proxy requires authentication.
*/
password?: string;
};

/**
* Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going
* on. Defaults to 0.
Expand Down
4 changes: 3 additions & 1 deletion packages/playwright-core/src/client/browserType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
exposeNetwork: params.exposeNetwork ?? params._exposeNetwork,
slowMo: params.slowMo,
timeout: params.timeout,
proxy: params.proxy,
};
if ((params as any).__testHookRedirectPortForwarding)
connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding;
Expand Down Expand Up @@ -188,7 +189,8 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
endpointURL,
headers,
slowMo: params.slowMo,
timeout: params.timeout
timeout: params.timeout,
proxy: params.proxy,
});
const browser = Browser.from(result.browser);
this._didLaunchBrowser(browser, {}, params.logger);
Expand Down
6 changes: 6 additions & 0 deletions packages/playwright-core/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ export type ConnectOptions = {
slowMo?: number,
timeout?: number,
logger?: Logger,
proxy?: {
server: string,
bypass?: string,
username?: string,
password?: string
},
};
export type LaunchServerOptions = {
channel?: channels.BrowserTypeLaunchOptions['channel'],
Expand Down
12 changes: 12 additions & 0 deletions packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,12 @@ scheme.LocalUtilsConnectParams = tObject({
exposeNetwork: tOptional(tString),
slowMo: tOptional(tNumber),
timeout: tOptional(tNumber),
proxy: tOptional(tObject({
server: tString,
bypass: tOptional(tString),
username: tOptional(tString),
password: tOptional(tString),
})),
socksProxyRedirectPortForTest: tOptional(tNumber),
});
scheme.LocalUtilsConnectResult = tObject({
Expand Down Expand Up @@ -650,6 +656,12 @@ scheme.BrowserTypeConnectOverCDPParams = tObject({
headers: tOptional(tArray(tType('NameValue'))),
slowMo: tOptional(tNumber),
timeout: tOptional(tNumber),
proxy: tOptional(tObject({
server: tString,
bypass: tOptional(tString),
username: tOptional(tString),
password: tOptional(tString),
})),
});
scheme.BrowserTypeConnectOverCDPResult = tObject({
browser: tChannel(['Browser']),
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/server/browserType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ export abstract class BrowserType extends SdkObject {
await fs.promises.mkdir(options.tracesDir, { recursive: true });
}

async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number }, timeout?: number): Promise<Browser> {
async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, proxy?: types.ProxySettings, timeout?: number, headers?: types.HeadersArray }): Promise<Browser> {
throw new Error('CDP connections are only supported by Chromium');
}

Expand Down
33 changes: 25 additions & 8 deletions packages/playwright-core/src/server/chromium/chromium.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,15 @@ export class Chromium extends BrowserType {
this._devtools = this._createDevTools();
}

override async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, headers?: types.HeadersArray }, timeout?: number) {
override async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, proxy?: types.ProxySettings, timeout?: number, headers?: types.HeadersArray }) {
const controller = new ProgressController(metadata, this);
controller.setLogName('browser');
return controller.run(async progress => {
return await this._connectOverCDPInternal(progress, endpointURL, options);
}, TimeoutSettings.timeout({ timeout }));
}, TimeoutSettings.timeout(options));
}

async _connectOverCDPInternal(progress: Progress, endpointURL: string, options: types.LaunchOptions & { headers?: types.HeadersArray }, onClose?: () => Promise<void>) {
async _connectOverCDPInternal(progress: Progress, endpointURL: string, options: types.LaunchOptions & { headers?: types.HeadersArray, proxy?: types.ProxySettings }, onClose?: () => Promise<void>) {
let headersMap: { [key: string]: string; } | undefined;
if (options.headers)
headersMap = headersArrayToObject(options.headers, false);
Expand All @@ -84,10 +84,10 @@ export class Chromium extends BrowserType {

const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER);

const wsEndpoint = await urlToWSEndpoint(progress, endpointURL, headersMap);
const wsEndpoint = await urlToWSEndpoint(progress, endpointURL, headersMap, options.proxy);
progress.throwIfAborted();

const chromeTransport = await WebSocketTransport.connect(progress, wsEndpoint, headersMap);
const chromeTransport = await WebSocketTransport.connect(progress, wsEndpoint, { headers: headersMap, proxy: options.proxy });
const cleanedUp = new ManualPromise<void>();
const doCleanup = async () => {
await removeFolders([artifactsDir]);
Expand Down Expand Up @@ -365,18 +365,35 @@ class ChromiumReadyState extends BrowserReadyState {
}
}

async function urlToWSEndpoint(progress: Progress, endpointURL: string, headers: { [key: string]: string; }) {
async function urlToWSEndpoint(progress: Progress, endpointURL: string, headers: { [key: string]: string; }, proxy?: types.ProxySettings) {
if (endpointURL.startsWith('ws'))
return endpointURL;
progress.log(`<ws preparing> retrieving websocket url from ${endpointURL}`);
const httpURL = endpointURL.endsWith('/') ? `${endpointURL}json/version/` : `${endpointURL}/json/version/`;
// Chromium insists on localhost "Host" header for security reasons, and in the case of proxy
// we end up with the remote host instead of localhost.
const extraHeaders = proxy ? { Host: `localhost:9222` } : {};
const json = await fetchData({
url: httpURL,
headers,
headers: { ...headers, ...extraHeaders },
proxy,
}, async (_, resp) => new Error(`Unexpected status ${resp.statusCode} when connecting to ${httpURL}.\n` +
`This does not look like a DevTools server, try connecting via ws://.`)
);
return JSON.parse(json).webSocketDebuggerUrl;
const wsUrl = JSON.parse(json).webSocketDebuggerUrl;
if (proxy) {
// webSocketDebuggerUrl will be a localhost URL, accessible from the browser's computer.
// When using a proxy, assume we need to connect through the original endpointURL.
// Example:
// connecting to http://example.com/
// making request to http://example.com/json/version/
// webSocketDebuggerUrl ends up as ws://localhost:9222/devtools/page/<guid>
// we construct ws://example.com/devtools/page/<guid> by appending the pathname to the original URL
const url = new URL(endpointURL);
url.pathname += (url.pathname.endsWith('/') ? '' : '/') + new URL(wsUrl).pathname.substring(1);
return url.toString();
}
return wsUrl;
}

async function seleniumErrorHandler(params: HTTPRequestParams, response: http.IncomingMessage) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export class BrowserTypeDispatcher extends Dispatcher<BrowserType, channels.Brow
}

async connectOverCDP(params: channels.BrowserTypeConnectOverCDPParams, metadata: CallMetadata): Promise<channels.BrowserTypeConnectOverCDPResult> {
const browser = await this._object.connectOverCDP(metadata, params.endpointURL, params, params.timeout);
const browser = await this._object.connectOverCDP(metadata, params.endpointURL, params);
const browserDispatcher = new BrowserDispatcher(this, browser);
return {
browser: browserDispatcher,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type { RootDispatcher } from './dispatcher';
import type * as channels from '@protocol/channels';
import type * as http from 'http';
import type { HTTPRequestParams } from '../utils/network';
import type { ProxySettings } from '../types';

export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel {
_type_LocalUtils: boolean;
Expand Down Expand Up @@ -90,9 +91,9 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
'x-playwright-proxy': params.exposeNetwork ?? '',
...params.headers,
};
const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint);
const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint, params.proxy);

const transport = await WebSocketTransport.connect(progress, wsEndpoint, wsHeaders, true, 'x-playwright-debug-log');
const transport = await WebSocketTransport.connect(progress, wsEndpoint, { headers: wsHeaders, followRedirects: true, debugLogHeader: 'x-playwright-debug-log', proxy: params.proxy });
const socksInterceptor = new SocksInterceptor(transport, params.exposeNetwork, params.socksProxyRedirectPortForTest);
const pipe = new JsonPipeDispatcher(this);
transport.onmessage = json => {
Expand Down Expand Up @@ -128,7 +129,7 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
}
}

async function urlToWSEndpoint(progress: Progress | undefined, endpointURL: string): Promise<string> {
async function urlToWSEndpoint(progress: Progress | undefined, endpointURL: string, proxy?: ProxySettings): Promise<string> {
if (endpointURL.startsWith('ws'))
return endpointURL;

Expand All @@ -142,6 +143,7 @@ async function urlToWSEndpoint(progress: Progress | undefined, endpointURL: stri
method: 'GET',
timeout: progress?.timeUntilDeadline() ?? 30_000,
headers: { 'User-Agent': getUserAgent() },
proxy,
}, 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://.`);
Expand Down
44 changes: 4 additions & 40 deletions packages/playwright-core/src/server/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,12 @@ import http from 'http';
import https from 'https';
import { Transform, pipeline } from 'stream';
import { TLSSocket } from 'tls';
import url from 'url';
import * as zlib from 'zlib';

import { TimeoutSettings } from './timeoutSettings';
import { assert, constructURLBasedOnBaseURL, eventsHelper, monotonicTime } from '../utils';
import { assert, constructURLBasedOnBaseURL, createProxyAgent, eventsHelper, monotonicTime } from '../utils';
import { createGuid } from './utils/crypto';
import { getUserAgent } from './utils/userAgent';
import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle';
import { BrowserContext, verifyClientCertificates } from './browserContext';
import { CookieStore, domainMatches, parseRawCookie } from './cookieStore';
import { MultipartFormData } from './formData';
Expand Down Expand Up @@ -183,8 +181,8 @@ export abstract class APIRequestContext extends SdkObject {
let agent;
// We skip 'per-context' in order to not break existing users. 'per-context' was previously used to
// workaround an upstream Chromium bug. Can be removed in the future.
if (proxy && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass))
agent = createProxyAgent(proxy);
if (proxy?.server !== 'per-context')
agent = createProxyAgent(proxy, requestUrl);

let maxRedirects = params.maxRedirects ?? (defaults.maxRedirects ?? 20);
maxRedirects = maxRedirects === 0 ? -1 : maxRedirects;
Expand Down Expand Up @@ -647,13 +645,6 @@ export class GlobalAPIRequestContext extends APIRequestContext {
const timeoutSettings = new TimeoutSettings();
if (options.timeout !== undefined)
timeoutSettings.setDefaultTimeout(options.timeout);
const proxy = options.proxy;
if (proxy?.server) {
let url = proxy?.server.trim();
if (!/^\w+:\/\//.test(url))
url = 'http://' + url;
proxy.server = url;
}
if (options.storageState) {
this._origins = options.storageState.origins?.map(origin => ({ indexedDB: [], ...origin }));
this._cookieStore.addCookies(options.storageState.cookies || []);
Expand All @@ -668,7 +659,7 @@ export class GlobalAPIRequestContext extends APIRequestContext {
maxRedirects: options.maxRedirects,
httpCredentials: options.httpCredentials,
clientCertificates: options.clientCertificates,
proxy,
proxy: options.proxy,
timeoutSettings,
};
this._tracing = new Tracing(this, options.tracesDir);
Expand Down Expand Up @@ -705,20 +696,6 @@ export class GlobalAPIRequestContext extends APIRequestContext {
}
}

export function createProxyAgent(proxy: types.ProxySettings) {
const proxyOpts = url.parse(proxy.server);
if (proxyOpts.protocol?.startsWith('socks')) {
return new SocksProxyAgent({
host: proxyOpts.hostname,
port: proxyOpts.port || undefined,
});
}
if (proxy.username)
proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`;
// TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method.
return new HttpsProxyAgent(proxyOpts);
}

function toHeadersArray(rawHeaders: string[]): types.HeadersArray {
const result: types.HeadersArray = [];
for (let i = 0; i < rawHeaders.length; i += 2)
Expand Down Expand Up @@ -791,19 +768,6 @@ function removeHeader(headers: { [name: string]: string }, name: string) {
delete headers[name];
}

function shouldBypassProxy(url: URL, bypass?: string): boolean {
if (!bypass)
return false;
const domains = bypass.split(',').map(s => {
s = s.trim();
if (!s.startsWith('.'))
s = '.' + s;
return s;
});
const domain = '.' + url.hostname;
return domains.some(d => domain.endsWith(d));
}

function setBasicAuthorizationHeader(headers: { [name: string]: string }, credentials: HTTPCredentials) {
const { username, password } = credentials;
const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64');
Expand Down
Loading
Loading