chore: refactor some code around proxies (#35572)

This commit is contained in:
Dmitry Gozman 2025-04-10 11:59:52 +00:00 committed by GitHub
parent f31e05bd07
commit 669cdcce3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 76 additions and 62 deletions

View File

@ -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, 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');
} }

View File

@ -64,12 +64,12 @@ 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, headers?: types.HeadersArray, timeout?: number }) {
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 }, onClose?: () => Promise<void>) {
@ -87,7 +87,7 @@ export class Chromium extends BrowserType {
const wsEndpoint = await urlToWSEndpoint(progress, endpointURL, headersMap); const wsEndpoint = await urlToWSEndpoint(progress, endpointURL, headersMap);
progress.throwIfAborted(); progress.throwIfAborted();
const chromeTransport = await WebSocketTransport.connect(progress, wsEndpoint, headersMap); const chromeTransport = await WebSocketTransport.connect(progress, wsEndpoint, { headers: headersMap });
const cleanedUp = new ManualPromise<void>(); const cleanedUp = new ManualPromise<void>();
const doCleanup = async () => { const doCleanup = async () => {
await removeFolders([artifactsDir]); await removeFolders([artifactsDir]);

View File

@ -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,

View File

@ -92,7 +92,7 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
}; };
const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint); const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint);
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' });
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 => {

View File

@ -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');

View File

@ -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('*');

View File

@ -60,6 +60,12 @@ export interface ConnectionTransport {
onclose?: (reason?: string) => void, onclose?: (reason?: string) => void,
} }
type WebSocketTransportOptions = {
headers?: { [key: string]: string; };
followRedirects?: boolean;
debugLogHeader?: string;
};
export class WebSocketTransport implements ConnectionTransport { export class WebSocketTransport implements ConnectionTransport {
private _ws: WebSocket; private _ws: WebSocket;
private _progress?: Progress; private _progress?: Progress;
@ -70,14 +76,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 +100,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 +123,32 @@ 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;
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: (/^(https|wss):\/\//.test(url)) ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent,
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]);
} }
}); });

View File

@ -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,
@ -109,6 +110,49 @@ 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 {