playwright/packages/playwright-core/src/utils/network.ts
Simon Knott de4a4d1ce1
fix(har timing): record connect timing for proxied connections (#32855)
Fixes a bug discovered in
https://github.com/microsoft/playwright/pull/32647. When using http
proxy, the `connect` event isn't emitted so we don't populate
`tcpConnectionAt`. The updated version of `https-proxy-agent` emits a
`proxyConnect` as a replacement, so this PR updates and listens to that
event.
For socks proxies, the `on("socket")` event is emitted once the SOCKS
connection is established, which is the equivalent of having a TCP
connection available.

---------

Signed-off-by: Simon Knott <info@simonknott.de>
Co-authored-by: Max Schmitt <max@schmitt.mx>
2024-10-07 09:59:13 +02:00

181 lines
6.8 KiB
TypeScript

/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import http from 'http';
import https from 'https';
import http2 from 'http2';
import type net from 'net';
import { getProxyForUrl } from '../utilsBundle';
import { HttpsProxyAgent } from '../utilsBundle';
import url from 'url';
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './happy-eyeballs';
export type HTTPRequestParams = {
url: string,
method?: string,
headers?: http.OutgoingHttpHeaders,
data?: string | Buffer,
timeout?: number,
rejectUnauthorized?: boolean,
};
export const NET_DEFAULT_TIMEOUT = 30_000;
export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.IncomingMessage) => void, onError: (error: Error) => void) {
const parsedUrl = url.parse(params.url);
let options: https.RequestOptions = {
...parsedUrl,
agent: parsedUrl.protocol === 'https:' ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent,
method: params.method || 'GET',
headers: params.headers,
};
if (params.rejectUnauthorized !== undefined)
options.rejectUnauthorized = params.rejectUnauthorized;
const timeout = params.timeout ?? NET_DEFAULT_TIMEOUT;
const proxyURL = getProxyForUrl(params.url);
if (proxyURL) {
const parsedProxyURL = new URL(proxyURL);
if (params.url.startsWith('http:')) {
options = {
path: parsedUrl.href,
host: parsedProxyURL.hostname,
port: parsedProxyURL.port,
headers: options.headers,
method: options.method
};
} else {
(parsedProxyURL as any).secureProxy = parsedProxyURL.protocol === 'https:';
options.agent = new HttpsProxyAgent(parsedProxyURL);
options.rejectUnauthorized = false;
}
}
const requestCallback = (res: http.IncomingMessage) => {
const statusCode = res.statusCode || 0;
if (statusCode >= 300 && statusCode < 400 && res.headers.location)
httpRequest({ ...params, url: new URL(res.headers.location, params.url).toString() }, onResponse, onError);
else
onResponse(res);
};
const request = options.protocol === 'https:' ?
https.request(options, requestCallback) :
http.request(options, requestCallback);
request.on('error', onError);
if (timeout !== undefined) {
const rejectOnTimeout = () => {
onError(new Error(`Request to ${params.url} timed out after ${timeout}ms`));
request.abort();
};
if (timeout <= 0) {
rejectOnTimeout();
return;
}
request.setTimeout(timeout, rejectOnTimeout);
}
request.end(params.data);
}
export function fetchData(params: HTTPRequestParams, onError?: (params: HTTPRequestParams, response: http.IncomingMessage) => Promise<Error>): Promise<string> {
return new Promise((resolve, reject) => {
httpRequest(params, async response => {
if (response.statusCode !== 200) {
const error = onError ? await onError(params, response) : new Error(`fetch failed: server returned code ${response.statusCode}. URL: ${params.url}`);
reject(error);
return;
}
let body = '';
response.on('data', (chunk: string) => body += chunk);
response.on('error', (error: any) => reject(error));
response.on('end', () => resolve(body));
}, reject);
});
}
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(...args: any[]): http.Server {
const server = http.createServer(...args);
decorateServer(server);
return server;
}
export function createHttpsServer(requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): https.Server;
export function createHttpsServer(options: https.ServerOptions, requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): https.Server;
export function createHttpsServer(...args: any[]): https.Server {
const server = https.createServer(...args);
decorateServer(server);
return server;
}
export function createHttp2Server( onRequestHandler?: (request: http2.Http2ServerRequest, response: http2.Http2ServerResponse) => void,): http2.Http2SecureServer;
export function createHttp2Server(options: http2.SecureServerOptions, onRequestHandler?: (request: http2.Http2ServerRequest, response: http2.Http2ServerResponse) => void,): http2.Http2SecureServer;
export function createHttp2Server(...args: any[]): http2.Http2SecureServer {
const server = http2.createSecureServer(...args);
decorateServer(server);
return server;
}
export async function isURLAvailable(url: URL, ignoreHTTPSErrors: boolean, onLog?: (data: string) => void, onStdErr?: (data: string) => void) {
let statusCode = await httpStatusCode(url, ignoreHTTPSErrors, onLog, onStdErr);
if (statusCode === 404 && url.pathname === '/') {
const indexUrl = new URL(url);
indexUrl.pathname = '/index.html';
statusCode = await httpStatusCode(indexUrl, ignoreHTTPSErrors, onLog, onStdErr);
}
return statusCode >= 200 && statusCode < 404;
}
async function httpStatusCode(url: URL, ignoreHTTPSErrors: boolean, onLog?: (data: string) => void, onStdErr?: (data: string) => void): Promise<number> {
return new Promise(resolve => {
onLog?.(`HTTP GET: ${url}`);
httpRequest({
url: url.toString(),
headers: { Accept: '*/*' },
rejectUnauthorized: !ignoreHTTPSErrors
}, res => {
res.resume();
const statusCode = res.statusCode ?? 0;
onLog?.(`HTTP Status: ${statusCode}`);
resolve(statusCode);
}, error => {
if ((error as NodeJS.ErrnoException).code === 'DEPTH_ZERO_SELF_SIGNED_CERT')
onStdErr?.(`[WebServer] Self-signed certificate detected. Try adding ignoreHTTPSErrors: true to config.webServer.`);
onLog?.(`Error while checking if ${url} is available: ${error.message}`);
resolve(0);
});
});
}
function decorateServer(server: net.Server) {
const sockets = new Set<net.Socket>();
server.on('connection', socket => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
});
const close = server.close;
server.close = (callback?: (err?: Error) => void) => {
for (const socket of sockets)
socket.destroy();
sockets.clear();
return close.call(server, callback);
};
}