feat: support proxy in connect/connectOverCDP (#35389)
This commit is contained in:
		
							parent
							
								
									e3bb687cfc
								
							
						
					
					
						commit
						471a28e0d5
					
				|  | @ -144,6 +144,16 @@ Some common examples: | ||||||
| 1. `"<loopback>"` to expose localhost network. | 1. `"<loopback>"` to expose localhost network. | ||||||
| 1. `"*.test.internal-domain,*.staging.internal-domain,<loopback>"` to expose test/staging deployments and localhost. | 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 | ## async method: BrowserType.connectOverCDP | ||||||
| * since: v1.9 | * since: v1.9 | ||||||
| - returns: <[Browser]> | - returns: <[Browser]> | ||||||
|  | @ -232,6 +242,16 @@ Logger sink for Playwright logging. Optional. | ||||||
| Maximum time in milliseconds to wait for the connection to be established. Defaults to | Maximum time in milliseconds to wait for the connection to be established. Defaults to | ||||||
| `30000` (30 seconds). Pass `0` to disable timeout. | `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 | ## method: BrowserType.executablePath | ||||||
| * since: v1.8 | * since: v1.8 | ||||||
| - returns: <[string]> | - returns: <[string]> | ||||||
|  |  | ||||||
|  | @ -226,10 +226,8 @@ Dangerous option; use with care. Defaults to `false`. | ||||||
| ## browser-option-proxy | ## browser-option-proxy | ||||||
| - `proxy` <[Object]> | - `proxy` <[Object]> | ||||||
|   - `server` <[string]> Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example |   - `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 |     `http://myproxy.com:3128` or `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy. | ||||||
|     proxy. |   - `bypass` ?<[string]> Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`. | ||||||
|   - `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. |   - `username` ?<[string]> Optional username to use if HTTP proxy requires authentication. | ||||||
|   - `password` ?<[string]> Optional password to use if HTTP proxy requires authentication. |   - `password` ?<[string]> Optional password to use if HTTP proxy requires authentication. | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -21793,6 +21793,34 @@ export interface ConnectOverCDPOptions { | ||||||
|    */ |    */ | ||||||
|   logger?: Logger; |   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 |    * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going | ||||||
|    * on. Defaults to 0. |    * on. Defaults to 0. | ||||||
|  | @ -21834,6 +21862,34 @@ export interface ConnectOptions { | ||||||
|    */ |    */ | ||||||
|   logger?: Logger; |   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 |    * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going | ||||||
|    * on. Defaults to 0. |    * on. Defaults to 0. | ||||||
|  |  | ||||||
|  | @ -129,6 +129,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple | ||||||
|         exposeNetwork: params.exposeNetwork ?? params._exposeNetwork, |         exposeNetwork: params.exposeNetwork ?? params._exposeNetwork, | ||||||
|         slowMo: params.slowMo, |         slowMo: params.slowMo, | ||||||
|         timeout: params.timeout, |         timeout: params.timeout, | ||||||
|  |         proxy: params.proxy, | ||||||
|       }; |       }; | ||||||
|       if ((params as any).__testHookRedirectPortForwarding) |       if ((params as any).__testHookRedirectPortForwarding) | ||||||
|         connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding; |         connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding; | ||||||
|  | @ -188,7 +189,8 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple | ||||||
|       endpointURL, |       endpointURL, | ||||||
|       headers, |       headers, | ||||||
|       slowMo: params.slowMo, |       slowMo: params.slowMo, | ||||||
|       timeout: params.timeout |       timeout: params.timeout, | ||||||
|  |       proxy: params.proxy, | ||||||
|     }); |     }); | ||||||
|     const browser = Browser.from(result.browser); |     const browser = Browser.from(result.browser); | ||||||
|     this._didLaunchBrowser(browser, {}, params.logger); |     this._didLaunchBrowser(browser, {}, params.logger); | ||||||
|  |  | ||||||
|  | @ -103,6 +103,12 @@ export type ConnectOptions = { | ||||||
|   slowMo?: number, |   slowMo?: number, | ||||||
|   timeout?: number, |   timeout?: number, | ||||||
|   logger?: Logger, |   logger?: Logger, | ||||||
|  |   proxy?: { | ||||||
|  |     server: string, | ||||||
|  |     bypass?: string, | ||||||
|  |     username?: string, | ||||||
|  |     password?: string | ||||||
|  |   }, | ||||||
| }; | }; | ||||||
| export type LaunchServerOptions = { | export type LaunchServerOptions = { | ||||||
|   channel?: channels.BrowserTypeLaunchOptions['channel'], |   channel?: channels.BrowserTypeLaunchOptions['channel'], | ||||||
|  |  | ||||||
|  | @ -327,6 +327,12 @@ scheme.LocalUtilsConnectParams = tObject({ | ||||||
|   exposeNetwork: tOptional(tString), |   exposeNetwork: tOptional(tString), | ||||||
|   slowMo: tOptional(tNumber), |   slowMo: tOptional(tNumber), | ||||||
|   timeout: tOptional(tNumber), |   timeout: tOptional(tNumber), | ||||||
|  |   proxy: tOptional(tObject({ | ||||||
|  |     server: tString, | ||||||
|  |     bypass: tOptional(tString), | ||||||
|  |     username: tOptional(tString), | ||||||
|  |     password: tOptional(tString), | ||||||
|  |   })), | ||||||
|   socksProxyRedirectPortForTest: tOptional(tNumber), |   socksProxyRedirectPortForTest: tOptional(tNumber), | ||||||
| }); | }); | ||||||
| scheme.LocalUtilsConnectResult = tObject({ | scheme.LocalUtilsConnectResult = tObject({ | ||||||
|  | @ -650,6 +656,12 @@ scheme.BrowserTypeConnectOverCDPParams = tObject({ | ||||||
|   headers: tOptional(tArray(tType('NameValue'))), |   headers: tOptional(tArray(tType('NameValue'))), | ||||||
|   slowMo: tOptional(tNumber), |   slowMo: tOptional(tNumber), | ||||||
|   timeout: tOptional(tNumber), |   timeout: tOptional(tNumber), | ||||||
|  |   proxy: tOptional(tObject({ | ||||||
|  |     server: tString, | ||||||
|  |     bypass: tOptional(tString), | ||||||
|  |     username: tOptional(tString), | ||||||
|  |     password: tOptional(tString), | ||||||
|  |   })), | ||||||
| }); | }); | ||||||
| scheme.BrowserTypeConnectOverCDPResult = tObject({ | scheme.BrowserTypeConnectOverCDPResult = tObject({ | ||||||
|   browser: tChannel(['Browser']), |   browser: tChannel(['Browser']), | ||||||
|  |  | ||||||
|  | @ -292,7 +292,7 @@ export abstract class BrowserType extends SdkObject { | ||||||
|       await fs.promises.mkdir(options.tracesDir, { recursive: true }); |       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'); |     throw new Error('CDP connections are only supported by Chromium'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -64,15 +64,15 @@ export class Chromium extends BrowserType { | ||||||
|       this._devtools = this._createDevTools(); |       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); |     const controller = new ProgressController(metadata, this); | ||||||
|     controller.setLogName('browser'); |     controller.setLogName('browser'); | ||||||
|     return controller.run(async progress => { |     return controller.run(async progress => { | ||||||
|       return await this._connectOverCDPInternal(progress, endpointURL, options); |       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; |     let headersMap: { [key: string]: string; } | undefined; | ||||||
|     if (options.headers) |     if (options.headers) | ||||||
|       headersMap = headersArrayToObject(options.headers, false); |       headersMap = headersArrayToObject(options.headers, false); | ||||||
|  | @ -84,10 +84,10 @@ export class Chromium extends BrowserType { | ||||||
| 
 | 
 | ||||||
|     const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER); |     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(); |     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 cleanedUp = new ManualPromise<void>(); | ||||||
|     const doCleanup = async () => { |     const doCleanup = async () => { | ||||||
|       await removeFolders([artifactsDir]); |       await removeFolders([artifactsDir]); | ||||||
|  | @ -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')) |   if (endpointURL.startsWith('ws')) | ||||||
|     return endpointURL; |     return endpointURL; | ||||||
|   progress.log(`<ws preparing> retrieving websocket url from ${endpointURL}`); |   progress.log(`<ws preparing> retrieving websocket url from ${endpointURL}`); | ||||||
|   const httpURL = endpointURL.endsWith('/') ? `${endpointURL}json/version/` : `${endpointURL}/json/version/`; |   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({ |   const json = await fetchData({ | ||||||
|     url: httpURL, |     url: httpURL, | ||||||
|     headers, |     headers: { ...headers, ...extraHeaders }, | ||||||
|  |     proxy, | ||||||
|   }, async (_, resp) => new Error(`Unexpected status ${resp.statusCode} when connecting to ${httpURL}.\n` + |   }, 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://.`) |     `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) { | async function seleniumErrorHandler(params: HTTPRequestParams, response: http.IncomingMessage) { | ||||||
|  |  | ||||||
|  | @ -43,7 +43,7 @@ export class BrowserTypeDispatcher extends Dispatcher<BrowserType, channels.Brow | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async connectOverCDP(params: channels.BrowserTypeConnectOverCDPParams, metadata: CallMetadata): Promise<channels.BrowserTypeConnectOverCDPResult> { |   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); |     const browserDispatcher = new BrowserDispatcher(this, browser); | ||||||
|     return { |     return { | ||||||
|       browser: browserDispatcher, |       browser: browserDispatcher, | ||||||
|  |  | ||||||
|  | @ -33,6 +33,7 @@ import type { RootDispatcher } from './dispatcher'; | ||||||
| import type * as channels from '@protocol/channels'; | import type * as channels from '@protocol/channels'; | ||||||
| import type * as http from 'http'; | import type * as http from 'http'; | ||||||
| import type { HTTPRequestParams } from '../utils/network'; | import type { HTTPRequestParams } from '../utils/network'; | ||||||
|  | import type { ProxySettings } from '../types'; | ||||||
| 
 | 
 | ||||||
| export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel { | export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel { | ||||||
|   _type_LocalUtils: boolean; |   _type_LocalUtils: boolean; | ||||||
|  | @ -90,9 +91,9 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. | ||||||
|         'x-playwright-proxy': params.exposeNetwork ?? '', |         'x-playwright-proxy': params.exposeNetwork ?? '', | ||||||
|         ...params.headers, |         ...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 socksInterceptor = new SocksInterceptor(transport, params.exposeNetwork, params.socksProxyRedirectPortForTest); | ||||||
|       const pipe = new JsonPipeDispatcher(this); |       const pipe = new JsonPipeDispatcher(this); | ||||||
|       transport.onmessage = json => { |       transport.onmessage = json => { | ||||||
|  | @ -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')) |   if (endpointURL.startsWith('ws')) | ||||||
|     return endpointURL; |     return endpointURL; | ||||||
| 
 | 
 | ||||||
|  | @ -142,6 +143,7 @@ async function urlToWSEndpoint(progress: Progress | undefined, endpointURL: stri | ||||||
|     method: 'GET', |     method: 'GET', | ||||||
|     timeout: progress?.timeUntilDeadline() ?? 30_000, |     timeout: progress?.timeUntilDeadline() ?? 30_000, | ||||||
|     headers: { 'User-Agent': getUserAgent() }, |     headers: { 'User-Agent': getUserAgent() }, | ||||||
|  |     proxy, | ||||||
|   }, async (params: HTTPRequestParams, response: http.IncomingMessage) => { |   }, async (params: HTTPRequestParams, response: http.IncomingMessage) => { | ||||||
|     return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` + |     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://.`); |         `This does not look like a Playwright server, try connecting via ws://.`); | ||||||
|  |  | ||||||
|  | @ -18,14 +18,12 @@ import http from 'http'; | ||||||
| import https from 'https'; | import https from 'https'; | ||||||
| import { Transform, pipeline } from 'stream'; | import { Transform, pipeline } from 'stream'; | ||||||
| import { TLSSocket } from 'tls'; | import { TLSSocket } from 'tls'; | ||||||
| import url from 'url'; |  | ||||||
| import * as zlib from 'zlib'; | import * as zlib from 'zlib'; | ||||||
| 
 | 
 | ||||||
| import { TimeoutSettings } from './timeoutSettings'; | 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 { createGuid } from './utils/crypto'; | ||||||
| import { getUserAgent } from './utils/userAgent'; | import { getUserAgent } from './utils/userAgent'; | ||||||
| import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle'; |  | ||||||
| import { BrowserContext, verifyClientCertificates } from './browserContext'; | import { BrowserContext, verifyClientCertificates } from './browserContext'; | ||||||
| import { CookieStore, domainMatches, parseRawCookie } from './cookieStore'; | import { CookieStore, domainMatches, parseRawCookie } from './cookieStore'; | ||||||
| import { MultipartFormData } from './formData'; | import { MultipartFormData } from './formData'; | ||||||
|  | @ -183,8 +181,8 @@ export abstract class APIRequestContext extends SdkObject { | ||||||
|     let agent; |     let agent; | ||||||
|     // We skip 'per-context' in order to not break existing users. 'per-context' was previously used to
 |     // 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.
 |     // workaround an upstream Chromium bug. Can be removed in the future.
 | ||||||
|     if (proxy && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass)) |     if (proxy?.server !== 'per-context') | ||||||
|       agent = createProxyAgent(proxy); |       agent = createProxyAgent(proxy, requestUrl); | ||||||
| 
 | 
 | ||||||
|     let maxRedirects = params.maxRedirects ?? (defaults.maxRedirects ?? 20); |     let maxRedirects = params.maxRedirects ?? (defaults.maxRedirects ?? 20); | ||||||
|     maxRedirects = maxRedirects === 0 ? -1 : maxRedirects; |     maxRedirects = maxRedirects === 0 ? -1 : maxRedirects; | ||||||
|  | @ -647,13 +645,6 @@ export class GlobalAPIRequestContext extends APIRequestContext { | ||||||
|     const timeoutSettings = new TimeoutSettings(); |     const timeoutSettings = new TimeoutSettings(); | ||||||
|     if (options.timeout !== undefined) |     if (options.timeout !== undefined) | ||||||
|       timeoutSettings.setDefaultTimeout(options.timeout); |       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) { |     if (options.storageState) { | ||||||
|       this._origins = options.storageState.origins?.map(origin => ({ indexedDB: [], ...origin })); |       this._origins = options.storageState.origins?.map(origin => ({ indexedDB: [], ...origin })); | ||||||
|       this._cookieStore.addCookies(options.storageState.cookies || []); |       this._cookieStore.addCookies(options.storageState.cookies || []); | ||||||
|  | @ -668,7 +659,7 @@ export class GlobalAPIRequestContext extends APIRequestContext { | ||||||
|       maxRedirects: options.maxRedirects, |       maxRedirects: options.maxRedirects, | ||||||
|       httpCredentials: options.httpCredentials, |       httpCredentials: options.httpCredentials, | ||||||
|       clientCertificates: options.clientCertificates, |       clientCertificates: options.clientCertificates, | ||||||
|       proxy, |       proxy: options.proxy, | ||||||
|       timeoutSettings, |       timeoutSettings, | ||||||
|     }; |     }; | ||||||
|     this._tracing = new Tracing(this, options.tracesDir); |     this._tracing = new Tracing(this, options.tracesDir); | ||||||
|  | @ -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 { | function toHeadersArray(rawHeaders: string[]): types.HeadersArray { | ||||||
|   const result: types.HeadersArray = []; |   const result: types.HeadersArray = []; | ||||||
|   for (let i = 0; i < rawHeaders.length; i += 2) |   for (let i = 0; i < rawHeaders.length; i += 2) | ||||||
|  | @ -791,19 +768,6 @@ function removeHeader(headers: { [name: string]: string }, name: string) { | ||||||
|   delete headers[name]; |   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) { | function setBasicAuthorizationHeader(headers: { [name: string]: string }, credentials: HTTPCredentials) { | ||||||
|   const { username, password } = credentials; |   const { username, password } = credentials; | ||||||
|   const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64'); |   const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64'); | ||||||
|  |  | ||||||
|  | @ -23,7 +23,7 @@ import tls from 'tls'; | ||||||
| import { SocksProxy } from './utils/socksProxy'; | import { SocksProxy } from './utils/socksProxy'; | ||||||
| import { ManualPromise, escapeHTML, generateSelfSignedCertificate, rewriteErrorMessage } from '../utils'; | import { ManualPromise, escapeHTML, generateSelfSignedCertificate, rewriteErrorMessage } from '../utils'; | ||||||
| import { verifyClientCertificates } from './browserContext'; | import { verifyClientCertificates } from './browserContext'; | ||||||
| import { createProxyAgent } from './fetch'; | import { createProxyAgent } from './utils/network'; | ||||||
| import { debugLogger } from './utils/debugLogger'; | import { debugLogger } from './utils/debugLogger'; | ||||||
| import { createSocket, createTLSSocket } from './utils/happyEyeballs'; | import { createSocket, createTLSSocket } from './utils/happyEyeballs'; | ||||||
| 
 | 
 | ||||||
|  | @ -242,7 +242,7 @@ export class ClientCertificatesProxy { | ||||||
|   ignoreHTTPSErrors: boolean | undefined; |   ignoreHTTPSErrors: boolean | undefined; | ||||||
|   secureContextMap: Map<string, tls.SecureContext> = new Map(); |   secureContextMap: Map<string, tls.SecureContext> = new Map(); | ||||||
|   alpnCache: ALPNCache; |   alpnCache: ALPNCache; | ||||||
|   proxyAgentFromOptions: ReturnType<typeof createProxyAgent> | undefined; |   proxyAgentFromOptions: ReturnType<typeof createProxyAgent>; | ||||||
| 
 | 
 | ||||||
|   constructor( |   constructor( | ||||||
|     contextOptions: Pick<types.BrowserContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors' | 'proxy'> |     contextOptions: Pick<types.BrowserContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors' | 'proxy'> | ||||||
|  | @ -250,7 +250,7 @@ export class ClientCertificatesProxy { | ||||||
|     verifyClientCertificates(contextOptions.clientCertificates); |     verifyClientCertificates(contextOptions.clientCertificates); | ||||||
|     this.alpnCache = new ALPNCache(); |     this.alpnCache = new ALPNCache(); | ||||||
|     this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors; |     this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors; | ||||||
|     this.proxyAgentFromOptions = contextOptions.proxy ? createProxyAgent(contextOptions.proxy) : undefined; |     this.proxyAgentFromOptions = createProxyAgent(contextOptions.proxy); | ||||||
|     this._initSecureContexts(contextOptions.clientCertificates); |     this._initSecureContexts(contextOptions.clientCertificates); | ||||||
|     this._socksProxy = new SocksProxy(); |     this._socksProxy = new SocksProxy(); | ||||||
|     this._socksProxy.setPattern('*'); |     this._socksProxy.setPattern('*'); | ||||||
|  |  | ||||||
|  | @ -15,13 +15,13 @@ | ||||||
|  * limitations under the License. |  * limitations under the License. | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| import { makeWaitForNextTask } from '../utils'; | import { createProxyAgent, makeWaitForNextTask } from '../utils'; | ||||||
| import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './utils/happyEyeballs'; | import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './utils/happyEyeballs'; | ||||||
| import { ws } from '../utilsBundle'; | import { ws } from '../utilsBundle'; | ||||||
| 
 | 
 | ||||||
| import type { WebSocket } from '../utilsBundle'; | import type { WebSocket } from '../utilsBundle'; | ||||||
| import type { Progress } from './progress'; | import type { Progress } from './progress'; | ||||||
| import type { HeadersArray } from './types'; | import type { HeadersArray, ProxySettings } from './types'; | ||||||
| import type { ClientRequest, IncomingMessage } from 'http'; | import type { ClientRequest, IncomingMessage } from 'http'; | ||||||
| 
 | 
 | ||||||
| export const perMessageDeflate = { | export const perMessageDeflate = { | ||||||
|  | @ -60,6 +60,13 @@ export interface ConnectionTransport { | ||||||
|   onclose?: (reason?: string) => void, |   onclose?: (reason?: string) => void, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | type WebSocketTransportOptions = { | ||||||
|  |   headers?: { [key: string]: string; }; | ||||||
|  |   followRedirects?: boolean; | ||||||
|  |   debugLogHeader?: string; | ||||||
|  |   proxy?: ProxySettings; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| export class WebSocketTransport implements ConnectionTransport { | export class WebSocketTransport implements ConnectionTransport { | ||||||
|   private _ws: WebSocket; |   private _ws: WebSocket; | ||||||
|   private _progress?: Progress; |   private _progress?: Progress; | ||||||
|  | @ -70,14 +77,14 @@ export class WebSocketTransport implements ConnectionTransport { | ||||||
|   readonly wsEndpoint: string; |   readonly wsEndpoint: string; | ||||||
|   readonly headers: HeadersArray = []; |   readonly headers: HeadersArray = []; | ||||||
| 
 | 
 | ||||||
|   static async connect(progress: (Progress|undefined), url: string, headers?: { [key: string]: string; }, followRedirects?: boolean, debugLogHeader?: string): Promise<WebSocketTransport> { |   static async connect(progress: (Progress|undefined), url: string, options: WebSocketTransportOptions = {}): Promise<WebSocketTransport> { | ||||||
|     return await WebSocketTransport._connect(progress, url, headers || {}, { follow: !!followRedirects, hadRedirects: false }, debugLogHeader); |     return await WebSocketTransport._connect(progress, url, options, false /* hadRedirects */); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   static async _connect(progress: (Progress|undefined), url: string, headers: { [key: string]: string; }, redirect: { follow: boolean, hadRedirects: boolean }, debugLogHeader?: string): Promise<WebSocketTransport> { |   static async _connect(progress: (Progress|undefined), url: string, options: WebSocketTransportOptions, hadRedirects: boolean): Promise<WebSocketTransport> { | ||||||
|     const logUrl = stripQueryParams(url); |     const logUrl = stripQueryParams(url); | ||||||
|     progress?.log(`<ws connecting> ${logUrl}`); |     progress?.log(`<ws connecting> ${logUrl}`); | ||||||
|     const transport = new WebSocketTransport(progress, url, logUrl, headers, redirect.follow && redirect.hadRedirects, debugLogHeader); |     const transport = new WebSocketTransport(progress, url, logUrl, { ...options, followRedirects: !!options.followRedirects && hadRedirects }); | ||||||
|     let success = false; |     let success = false; | ||||||
|     progress?.cleanupWhenAborted(async () => { |     progress?.cleanupWhenAborted(async () => { | ||||||
|       if (!success) |       if (!success) | ||||||
|  | @ -94,13 +101,13 @@ export class WebSocketTransport implements ConnectionTransport { | ||||||
|         transport._ws.close(); |         transport._ws.close(); | ||||||
|       }); |       }); | ||||||
|       transport._ws.on('unexpected-response', (request: ClientRequest, response: IncomingMessage) => { |       transport._ws.on('unexpected-response', (request: ClientRequest, response: IncomingMessage) => { | ||||||
|         if (redirect.follow && !redirect.hadRedirects && (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307 || response.statusCode === 308)) { |         if (options.followRedirects && !hadRedirects && (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307 || response.statusCode === 308)) { | ||||||
|           fulfill({ redirect: response }); |           fulfill({ redirect: response }); | ||||||
|           transport._ws.close(); |           transport._ws.close(); | ||||||
|           return; |           return; | ||||||
|         } |         } | ||||||
|         for (let i = 0; i < response.rawHeaders.length; i += 2) { |         for (let i = 0; i < response.rawHeaders.length; i += 2) { | ||||||
|           if (debugLogHeader && response.rawHeaders[i] === debugLogHeader) |           if (options.debugLogHeader && response.rawHeaders[i] === options.debugLogHeader) | ||||||
|             progress?.log(response.rawHeaders[i + 1]); |             progress?.log(response.rawHeaders[i + 1]); | ||||||
|         } |         } | ||||||
|         const chunks: Buffer[] = []; |         const chunks: Buffer[] = []; | ||||||
|  | @ -117,32 +124,34 @@ export class WebSocketTransport implements ConnectionTransport { | ||||||
| 
 | 
 | ||||||
|     if (result.redirect) { |     if (result.redirect) { | ||||||
|       // Strip authorization headers from the redirected request.
 |       // Strip authorization headers from the redirected request.
 | ||||||
|       const newHeaders = Object.fromEntries(Object.entries(headers || {}).filter(([name]) => { |       const newHeaders = Object.fromEntries(Object.entries(options.headers || {}).filter(([name]) => { | ||||||
|         return !name.includes('access-key') && name.toLowerCase() !== 'authorization'; |         return !name.includes('access-key') && name.toLowerCase() !== 'authorization'; | ||||||
|       })); |       })); | ||||||
|       return WebSocketTransport._connect(progress, result.redirect.headers.location!, newHeaders, { follow: true, hadRedirects: true }, debugLogHeader); |       return WebSocketTransport._connect(progress, result.redirect.headers.location!, { ...options, headers: newHeaders }, true /* hadRedirects */); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     success = true; |     success = true; | ||||||
|     return transport; |     return transport; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   constructor(progress: Progress|undefined, url: string, logUrl: string, headers?: { [key: string]: string; }, followRedirects?: boolean, debugLogHeader?: string) { |   constructor(progress: Progress|undefined, url: string, logUrl: string, options: WebSocketTransportOptions) { | ||||||
|     this.wsEndpoint = url; |     this.wsEndpoint = url; | ||||||
|     this._logUrl = logUrl; |     this._logUrl = logUrl; | ||||||
|  |     const proxyAgent = createProxyAgent(options.proxy, new URL(url)); | ||||||
|  |     const happyEyeballsAgent = (/^(https|wss):\/\//.test(url)) ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent; | ||||||
|     this._ws = new ws(url, [], { |     this._ws = new ws(url, [], { | ||||||
|       maxPayload: 256 * 1024 * 1024, // 256Mb,
 |       maxPayload: 256 * 1024 * 1024, // 256Mb,
 | ||||||
|       // Prevent internal http client error when passing negative timeout.
 |       // Prevent internal http client error when passing negative timeout.
 | ||||||
|       handshakeTimeout: Math.max(progress?.timeUntilDeadline() ?? 30_000, 1), |       handshakeTimeout: Math.max(progress?.timeUntilDeadline() ?? 30_000, 1), | ||||||
|       headers, |       headers: options.headers, | ||||||
|       followRedirects, |       followRedirects: options.followRedirects, | ||||||
|       agent: (/^(https|wss):\/\//.test(url)) ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent, |       agent: proxyAgent || happyEyeballsAgent, | ||||||
|       perMessageDeflate, |       perMessageDeflate, | ||||||
|     }); |     }); | ||||||
|     this._ws.on('upgrade', response => { |     this._ws.on('upgrade', response => { | ||||||
|       for (let i = 0; i < response.rawHeaders.length; i += 2) { |       for (let i = 0; i < response.rawHeaders.length; i += 2) { | ||||||
|         this.headers.push({ name: response.rawHeaders[i], value: response.rawHeaders[i + 1] }); |         this.headers.push({ name: response.rawHeaders[i], value: response.rawHeaders[i + 1] }); | ||||||
|         if (debugLogHeader && response.rawHeaders[i] === debugLogHeader) |         if (options.debugLogHeader && response.rawHeaders[i] === options.debugLogHeader) | ||||||
|           progress?.log(response.rawHeaders[i + 1]); |           progress?.log(response.rawHeaders[i + 1]); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  | @ -20,10 +20,11 @@ import http2 from 'http2'; | ||||||
| import https from 'https'; | import https from 'https'; | ||||||
| import url from 'url'; | import url from 'url'; | ||||||
| 
 | 
 | ||||||
| import { HttpsProxyAgent, getProxyForUrl } from '../../utilsBundle'; | import { HttpsProxyAgent, SocksProxyAgent, getProxyForUrl } from '../../utilsBundle'; | ||||||
| import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './happyEyeballs'; | import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './happyEyeballs'; | ||||||
| 
 | 
 | ||||||
| import type net from 'net'; | import type net from 'net'; | ||||||
|  | import type { ProxySettings } from '../types'; | ||||||
| 
 | 
 | ||||||
| export type HTTPRequestParams = { | export type HTTPRequestParams = { | ||||||
|   url: string, |   url: string, | ||||||
|  | @ -32,6 +33,7 @@ export type HTTPRequestParams = { | ||||||
|   data?: string | Buffer, |   data?: string | Buffer, | ||||||
|   timeout?: number, |   timeout?: number, | ||||||
|   rejectUnauthorized?: boolean, |   rejectUnauthorized?: boolean, | ||||||
|  |   proxy?: ProxySettings, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const NET_DEFAULT_TIMEOUT = 30_000; | export const NET_DEFAULT_TIMEOUT = 30_000; | ||||||
|  | @ -49,22 +51,26 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco | ||||||
| 
 | 
 | ||||||
|   const timeout = params.timeout ?? NET_DEFAULT_TIMEOUT; |   const timeout = params.timeout ?? NET_DEFAULT_TIMEOUT; | ||||||
| 
 | 
 | ||||||
|   const proxyURL = getProxyForUrl(params.url); |   if (params.proxy) { | ||||||
|   if (proxyURL) { |     options.agent = createProxyAgent(params.proxy, new URL(params.url)); | ||||||
|     const parsedProxyURL = url.parse(proxyURL); |   } else { | ||||||
|     if (params.url.startsWith('http:')) { |     const proxyURL = getProxyForUrl(params.url); | ||||||
|       options = { |     if (proxyURL) { | ||||||
|         path: parsedUrl.href, |       const parsedProxyURL = url.parse(proxyURL); | ||||||
|         host: parsedProxyURL.hostname, |       if (params.url.startsWith('http:')) { | ||||||
|         port: parsedProxyURL.port, |         options = { | ||||||
|         headers: options.headers, |           path: parsedUrl.href, | ||||||
|         method: options.method |           host: parsedProxyURL.hostname, | ||||||
|       }; |           port: parsedProxyURL.port, | ||||||
|     } else { |           headers: options.headers, | ||||||
|       (parsedProxyURL as any).secureProxy = parsedProxyURL.protocol === 'https:'; |           method: options.method | ||||||
|  |         }; | ||||||
|  |       } else { | ||||||
|  |         (parsedProxyURL as any).secureProxy = parsedProxyURL.protocol === 'https:'; | ||||||
| 
 | 
 | ||||||
|       options.agent = new HttpsProxyAgent(parsedProxyURL); |         options.agent = new HttpsProxyAgent(parsedProxyURL); | ||||||
|       options.rejectUnauthorized = false; |         options.rejectUnauthorized = false; | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -109,6 +115,47 @@ export function fetchData(params: HTTPRequestParams, onError?: (params: HTTPRequ | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | 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)); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function createProxyAgent(proxy?: ProxySettings, forUrl?: URL) { | ||||||
|  |   if (!proxy) | ||||||
|  |     return; | ||||||
|  |   if (forUrl && proxy.bypass && shouldBypassProxy(forUrl, proxy.bypass)) | ||||||
|  |     return; | ||||||
|  | 
 | ||||||
|  |   // Browsers allow to specify proxy without a protocol, defaulting to http.
 | ||||||
|  |   let proxyServer = proxy.server.trim(); | ||||||
|  |   if (!/^\w+:\/\//.test(proxyServer)) | ||||||
|  |     proxyServer = 'http://' + proxyServer; | ||||||
|  | 
 | ||||||
|  |   const proxyOpts = url.parse(proxyServer); | ||||||
|  |   if (proxyOpts.protocol?.startsWith('socks')) { | ||||||
|  |     return new SocksProxyAgent({ | ||||||
|  |       host: proxyOpts.hostname, | ||||||
|  |       port: proxyOpts.port || undefined, | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |   if (proxy.username) | ||||||
|  |     proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`; | ||||||
|  |   if (forUrl && ['ws:', 'wss:'].includes(forUrl.protocol)) { | ||||||
|  |     // Force CONNECT method for WebSockets.
 | ||||||
|  |     return new HttpsProxyAgent(proxyOpts); | ||||||
|  |   } | ||||||
|  |   // TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method.
 | ||||||
|  |   return new HttpsProxyAgent(proxyOpts); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export function createHttpServer(requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server; | export function createHttpServer(requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server; | ||||||
| export function createHttpServer(options: http.ServerOptions, requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server; | export function createHttpServer(options: http.ServerOptions, requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server; | ||||||
| export function createHttpServer(...args: any[]): http.Server { | export function createHttpServer(...args: any[]): http.Server { | ||||||
|  |  | ||||||
|  | @ -21793,6 +21793,34 @@ export interface ConnectOverCDPOptions { | ||||||
|    */ |    */ | ||||||
|   logger?: Logger; |   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 |    * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going | ||||||
|    * on. Defaults to 0. |    * on. Defaults to 0. | ||||||
|  | @ -21834,6 +21862,34 @@ export interface ConnectOptions { | ||||||
|    */ |    */ | ||||||
|   logger?: Logger; |   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 |    * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going | ||||||
|    * on. Defaults to 0. |    * on. Defaults to 0. | ||||||
|  |  | ||||||
|  | @ -540,6 +540,12 @@ export type LocalUtilsConnectParams = { | ||||||
|   exposeNetwork?: string, |   exposeNetwork?: string, | ||||||
|   slowMo?: number, |   slowMo?: number, | ||||||
|   timeout?: number, |   timeout?: number, | ||||||
|  |   proxy?: { | ||||||
|  |     server: string, | ||||||
|  |     bypass?: string, | ||||||
|  |     username?: string, | ||||||
|  |     password?: string, | ||||||
|  |   }, | ||||||
|   socksProxyRedirectPortForTest?: number, |   socksProxyRedirectPortForTest?: number, | ||||||
| }; | }; | ||||||
| export type LocalUtilsConnectOptions = { | export type LocalUtilsConnectOptions = { | ||||||
|  | @ -547,6 +553,12 @@ export type LocalUtilsConnectOptions = { | ||||||
|   exposeNetwork?: string, |   exposeNetwork?: string, | ||||||
|   slowMo?: number, |   slowMo?: number, | ||||||
|   timeout?: number, |   timeout?: number, | ||||||
|  |   proxy?: { | ||||||
|  |     server: string, | ||||||
|  |     bypass?: string, | ||||||
|  |     username?: string, | ||||||
|  |     password?: string, | ||||||
|  |   }, | ||||||
|   socksProxyRedirectPortForTest?: number, |   socksProxyRedirectPortForTest?: number, | ||||||
| }; | }; | ||||||
| export type LocalUtilsConnectResult = { | export type LocalUtilsConnectResult = { | ||||||
|  | @ -1165,11 +1177,23 @@ export type BrowserTypeConnectOverCDPParams = { | ||||||
|   headers?: NameValue[], |   headers?: NameValue[], | ||||||
|   slowMo?: number, |   slowMo?: number, | ||||||
|   timeout?: number, |   timeout?: number, | ||||||
|  |   proxy?: { | ||||||
|  |     server: string, | ||||||
|  |     bypass?: string, | ||||||
|  |     username?: string, | ||||||
|  |     password?: string, | ||||||
|  |   }, | ||||||
| }; | }; | ||||||
| export type BrowserTypeConnectOverCDPOptions = { | export type BrowserTypeConnectOverCDPOptions = { | ||||||
|   headers?: NameValue[], |   headers?: NameValue[], | ||||||
|   slowMo?: number, |   slowMo?: number, | ||||||
|   timeout?: number, |   timeout?: number, | ||||||
|  |   proxy?: { | ||||||
|  |     server: string, | ||||||
|  |     bypass?: string, | ||||||
|  |     username?: string, | ||||||
|  |     password?: string, | ||||||
|  |   }, | ||||||
| }; | }; | ||||||
| export type BrowserTypeConnectOverCDPResult = { | export type BrowserTypeConnectOverCDPResult = { | ||||||
|   browser: BrowserChannel, |   browser: BrowserChannel, | ||||||
|  |  | ||||||
|  | @ -702,6 +702,13 @@ LocalUtils: | ||||||
|         exposeNetwork: string? |         exposeNetwork: string? | ||||||
|         slowMo: number? |         slowMo: number? | ||||||
|         timeout: number? |         timeout: number? | ||||||
|  |         proxy: | ||||||
|  |           type: object? | ||||||
|  |           properties: | ||||||
|  |             server: string | ||||||
|  |             bypass: string? | ||||||
|  |             username: string? | ||||||
|  |             password: string? | ||||||
|         socksProxyRedirectPortForTest: number? |         socksProxyRedirectPortForTest: number? | ||||||
|       returns: |       returns: | ||||||
|         pipe: JsonPipe |         pipe: JsonPipe | ||||||
|  | @ -1010,6 +1017,13 @@ BrowserType: | ||||||
|           items: NameValue |           items: NameValue | ||||||
|         slowMo: number? |         slowMo: number? | ||||||
|         timeout: number? |         timeout: number? | ||||||
|  |         proxy: | ||||||
|  |           type: object? | ||||||
|  |           properties: | ||||||
|  |             server: string | ||||||
|  |             bypass: string? | ||||||
|  |             username: string? | ||||||
|  |             password: string? | ||||||
|       returns: |       returns: | ||||||
|         browser: Browser |         browser: Browser | ||||||
|         defaultContext: BrowserContext? |         defaultContext: BrowserContext? | ||||||
|  |  | ||||||
|  | @ -765,6 +765,24 @@ for (const kind of ['launchServer', 'run-server'] as const) { | ||||||
|       await browser.close(); |       await browser.close(); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     test('should connect over http proxy', { | ||||||
|  |       annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33894' }, | ||||||
|  |     }, async ({ connect, startRemoteServer, proxyServer }) => { | ||||||
|  |       const remoteServer = await startRemoteServer(kind); | ||||||
|  |       const url = new URL(remoteServer.wsEndpoint()); | ||||||
|  |       proxyServer.forwardTo(+url.port, { allowConnectRequests: true }); | ||||||
|  |       const browser = await connect(`http://some.random.host.does.not.exist:1337`, { | ||||||
|  |         proxy: { server: `localhost:${proxyServer.PORT}` }, | ||||||
|  |       }); | ||||||
|  |       const page = await browser.newPage(); | ||||||
|  |       expect(await page.evaluate('11 * 11')).toBe(121); | ||||||
|  |       await browser.close(); | ||||||
|  |       // We should "CONNECT" twice:
 | ||||||
|  |       // - to convert http url into ws url
 | ||||||
|  |       // - actually connect to the ws endpoint
 | ||||||
|  |       expect(proxyServer.connectHosts).toEqual(['some.random.host.does.not.exist:1337', 'some.random.host.does.not.exist:1337']); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     test.describe('socks proxy', () => { |     test.describe('socks proxy', () => { | ||||||
|       test.skip(({ mode }) => mode !== 'default'); |       test.skip(({ mode }) => mode !== 'default'); | ||||||
|       test.skip(kind === 'launchServer', 'not supported yet'); |       test.skip(kind === 'launchServer', 'not supported yet'); | ||||||
|  |  | ||||||
|  | @ -446,6 +446,32 @@ test('should be able to connect via localhost', async ({ browserType }, testInfo | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
|  | test('should be able to connect over http proxy', { | ||||||
|  |   annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/35206' }, | ||||||
|  | }, async ({ browserType, proxyServer }, testInfo) => { | ||||||
|  |   const port = 9339 + testInfo.workerIndex; | ||||||
|  |   const browserServer = await browserType.launch({ | ||||||
|  |     args: ['--remote-debugging-port=' + port] | ||||||
|  |   }); | ||||||
|  |   proxyServer.forwardTo(port, { allowConnectRequests: true }); | ||||||
|  |   try { | ||||||
|  |     const cdpBrowser = await browserType.connectOverCDP(`http://some.random.host.does.not.exist:1337`, { | ||||||
|  |       proxy: { server: `localhost:${proxyServer.PORT}` }, | ||||||
|  |     }); | ||||||
|  |     const contexts = cdpBrowser.contexts(); | ||||||
|  |     expect(contexts.length).toBe(1); | ||||||
|  |     const page = await contexts[0].newPage(); | ||||||
|  |     expect(await page.evaluate('11 * 11')).toBe(121); | ||||||
|  |     await cdpBrowser.close(); | ||||||
|  |     // We should "CONNECT" twice:
 | ||||||
|  |     // - to convert http url into ws url
 | ||||||
|  |     // - actually connect to the ws endpoint
 | ||||||
|  |     expect(proxyServer.connectHosts).toEqual(['some.random.host.does.not.exist:1337', 'some.random.host.does.not.exist:1337']); | ||||||
|  |   } finally { | ||||||
|  |     await browserServer.close(); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| test('emulate media should not be affected by second connectOverCDP', async ({ browserType }, testInfo) => { | test('emulate media should not be affected by second connectOverCDP', async ({ browserType }, testInfo) => { | ||||||
|   test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/24109' }); |   test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/24109' }); | ||||||
|   test.fixme(); |   test.fixme(); | ||||||
|  |  | ||||||
|  | @ -323,7 +323,7 @@ it('should use SOCKS proxy for websocket requests', async ({ browserType, server | ||||||
|   await closeProxyServer(); |   await closeProxyServer(); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| it('should use http proxy for websocket requests', async ({ browserName, browserType, server, proxyServer }) => { | it('should use http proxy for websocket requests', async ({ isWindows, browserName, browserType, server, proxyServer }) => { | ||||||
|   proxyServer.forwardTo(server.PORT, { allowConnectRequests: true }); |   proxyServer.forwardTo(server.PORT, { allowConnectRequests: true }); | ||||||
|   const browser = await browserType.launch({ |   const browser = await browserType.launch({ | ||||||
|     proxy: { server: `localhost:${proxyServer.PORT}` } |     proxy: { server: `localhost:${proxyServer.PORT}` } | ||||||
|  | @ -350,7 +350,7 @@ it('should use http proxy for websocket requests', async ({ browserName, browser | ||||||
| 
 | 
 | ||||||
|   // WebKit does not use CONNECT for websockets, but other browsers do.
 |   // WebKit does not use CONNECT for websockets, but other browsers do.
 | ||||||
|   if (browserName === 'webkit') |   if (browserName === 'webkit') | ||||||
|     expect(proxyServer.wsUrls).toContain('ws://fake-localhost-127-0-0-1.nip.io:1337/ws'); |     expect(proxyServer.wsUrls).toContain(isWindows ? '/ws' : 'ws://fake-localhost-127-0-0-1.nip.io:1337/ws'); | ||||||
|   else |   else | ||||||
|     expect(proxyServer.connectHosts).toContain('fake-localhost-127-0-0-1.nip.io:1337'); |     expect(proxyServer.connectHosts).toContain('fake-localhost-127-0-0-1.nip.io:1337'); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue