import type EventEmitter from "node:events"; import type { FileHandle } from "node:fs/promises"; import type { ParsedUrlQueryInput } from "node:querystring"; import type * as Undici from "undici"; import { open } from "node:fs/promises"; import querystring from "node:querystring"; import { pipeline } from "node:stream/promises"; import { ensureError } from "./error.js"; import { DownloadError, RequestError, DispatcherError, } from "./errors/request.js"; import { move, remove } from "./fs.js"; import { generateTempFilePath, getBaseDispatcherOptions, getBaseRequestOptions, getBasicDispatcher, getPoolDispatcher, getProxyDispatcher, handleError, } from "./internal/request.js"; export const DEFAULT_TIMEOUT_IN_MILLISECONDS = 300_000; // Aligned with undici export const DEFAULT_MAX_REDIRECTS = 10; export const DEFAULT_POOL_MAX_CONNECTIONS = 128; export const DEFAULT_USER_AGENT = "Hardhat"; export type Dispatcher = Undici.Dispatcher; export type TestDispatcher = Undici.MockAgent; export type Interceptable = Undici.Interceptable; // We don't load undici on startup because this package is transitively imported // from too many places and it's too complex to optimize case by case. let undici: typeof Undici | undefined; /** * Options to configure the dispatcher. * * @param timeout The timeout in milliseconds. Defaults to {@link DEFAULT_TIMEOUT_IN_MILLISECONDS}. * @param proxy The proxy to use. If not provided, no proxy is used. * @param pool Whether to use a pool dispatcher. Defaults to `false`. * @param maxConnections The maximum number of connections to use in the pool. Defaults to {@link DEFAULT_POOL_MAX_CONNECTIONS}. * @param isTestDispatcher Whether to use a test dispatcher. Defaults to `false`. It's highly recommended to use a test dispatcher in tests to avoid hanging tests. */ export interface DispatcherOptions { timeout?: number; proxy?: string; pool?: boolean; maxConnections?: number; isTestDispatcher?: boolean; } /** * Options to configure a request. * * @param queryParams The query parameters to append to the url. * @param extraHeaders Additional headers to include in the request. * @param abortSignal The signal to abort the request. */ export interface RequestOptions { queryParams?: Record; extraHeaders?: Record; abortSignal?: AbortSignal | EventEmitter; } export interface HttpResponse { statusCode: number; body: { json(): Promise; text(): Promise; }; } /** * Performs a HTTP request. * * @param url The url to make the request to. * @param requestOptions The options to configure the request. See {@link RequestOptions}. * @param dispatcherOrDispatcherOptions Either a dispatcher or dispatcher options. See {@link DispatcherOptions}. * @returns An object containing the status code and the response body. The body can be accessed as JSON or text. * `body` can not be consumed twice. For example, calling `text()` after `json()` throws `TypeError`. * @throws ConnectionRefusedError If the connection is refused by the server. * @throws RequestTimeoutError If the request times out. * @throws RequestError If the request fails for any other reason. */ export async function getRequest( url: string, requestOptions: RequestOptions = {}, dispatcherOrDispatcherOptions?: Undici.Dispatcher | DispatcherOptions, ): Promise { if (undici === undefined) { undici = await import("undici"); } try { const baseRequestOptions = await getBaseRequestOptions( url, requestOptions, dispatcherOrDispatcherOptions, ); return await undici.request(url, { method: "GET", ...baseRequestOptions, }); } catch (e) { ensureError(e); handleError(e, url); throw new RequestError(url, "GET", e); } } /** * Performs a POST request with a JSON body. * * @param url The url to make the request to. * @param body The body of the request, represented as an object. * @param requestOptions The options to configure the request. See {@link RequestOptions}. * @param dispatcherOrDispatcherOptions Either a dispatcher or dispatcher options. See {@link DispatcherOptions}. * @returns An object containing the status code and the response body. The body can be accessed as JSON or text. * `body` can not be consumed twice. For example, calling `text()` after `json()` throws `TypeError`. * @throws ConnectionRefusedError If the connection is refused by the server. * @throws RequestTimeoutError If the request times out. * @throws RequestError If the request fails for any other reason. */ export async function postJsonRequest( url: string, body: unknown, requestOptions: RequestOptions = {}, dispatcherOrDispatcherOptions?: Undici.Dispatcher | DispatcherOptions, ): Promise { if (undici === undefined) { undici = await import("undici"); } try { const { headers, ...baseRequestOptions } = await getBaseRequestOptions( url, requestOptions, dispatcherOrDispatcherOptions, ); return await undici.request(url, { method: "POST", ...baseRequestOptions, headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify(body), }); } catch (e) { ensureError(e); handleError(e, url); throw new RequestError(url, "POST", e); } } /** * Performs a POST request with a form body. * * @param url The url to make the request to. * @param body The body of the request, represented as an object. * @param requestOptions The options to configure the request. See {@link RequestOptions}. * @param dispatcherOrDispatcherOptions Either a dispatcher or dispatcher options. See {@link DispatcherOptions}. * @returns An object containing the status code and the response body. The body can be accessed as JSON or text. * `body` can not be consumed twice. For example, calling `text()` after `json()` throws `TypeError`. * @throws ConnectionRefusedError If the connection is refused by the server. * @throws RequestTimeoutError If the request times out. * @throws RequestError If the request fails for any other reason. */ export async function postFormRequest( url: string, body: unknown, requestOptions: RequestOptions = {}, dispatcherOrDispatcherOptions?: Undici.Dispatcher | DispatcherOptions, ): Promise { if (undici === undefined) { undici = await import("undici"); } try { const { headers, ...baseRequestOptions } = await getBaseRequestOptions( url, requestOptions, dispatcherOrDispatcherOptions, ); return await undici.request(url, { method: "POST", ...baseRequestOptions, headers: { ...headers, "Content-Type": "application/x-www-form-urlencoded", }, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TODO: Add a runtime check for body's type body: querystring.stringify(body as ParsedUrlQueryInput), }); } catch (e) { ensureError(e); handleError(e, url); throw new RequestError(url, "POST", e); } } /** * Downloads a file from a url to a destination path. * * @param url The url to download from. * @param destination The absolute path to save the file to. * @param requestOptions The options to configure the request. See {@link RequestOptions}. * @param dispatcherOrDispatcherOptions Either a dispatcher or dispatcher options. See {@link DispatcherOptions}. * @throws ConnectionRefusedError If the connection is refused by the server. * @throws RequestTimeoutError If the request times out. * @throws DownloadFailedError If the download fails for any other reason. */ export async function download( url: string, destination: string, requestOptions: RequestOptions = {}, dispatcherOrDispatcherOptions?: Undici.Dispatcher | DispatcherOptions, ): Promise { let statusCode: number | undefined; let tempFilePath: string | undefined; try { /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We need the full Dispatcher.ResponseData here for stream.pipeline, but HttpResponse doesn't expose the raw ReadableStream. TODO: wrap undici's request so we can keep the public API strictly typed without falling back to Undici types. */ const response = (await getRequest( url, requestOptions, dispatcherOrDispatcherOptions, )) as Undici.Dispatcher.ResponseData; const { body } = response; statusCode = response.statusCode; if (statusCode < 200 || statusCode >= 300) { throw new Error(await body.text()); } tempFilePath = await generateTempFilePath(destination); let fileHandle: FileHandle | undefined; try { fileHandle = await open(tempFilePath, "w"); const fileStream = fileHandle.createWriteStream(); await pipeline(body, fileStream); } finally { // NOTE: Historically, not closing the file handle caused issues on Windows, // for example, when trying to move the file previously written to by this function await fileHandle?.close(); } await move(tempFilePath, destination); } catch (e) { ensureError(e); if (tempFilePath !== undefined) { try { await remove(tempFilePath); } catch { // Best-effort: file may not exist or may have already been moved } } handleError(e, url); throw new DownloadError(url, e); } } /** * Creates a dispatcher based on the provided options. * If the `proxy` option is set, it creates a {@link Undici.ProxyAgent} dispatcher. * If the `pool` option is set to `true`, it creates a {@link Undici.Pool} dispatcher. * Otherwise, it creates a basic {@link Undici.Agent} dispatcher. * * @param url The url to make requests to. * @param options The options to configure the dispatcher. See {@link DispatcherOptions}. * @returns The configured dispatcher instance. * @throws DispatcherError If the dispatcher can't be created. */ export async function getDispatcher( url: string, { timeout, proxy, pool, maxConnections, isTestDispatcher, }: DispatcherOptions = {}, ): Promise { try { if (pool !== undefined && proxy !== undefined) { throw new Error( "The pool and proxy options can't be used at the same time", ); } const baseOptions = getBaseDispatcherOptions(timeout, isTestDispatcher); if (proxy !== undefined) { return await getProxyDispatcher(proxy, baseOptions); } if (pool === true) { return await getPoolDispatcher(url, { ...baseOptions, connections: maxConnections ?? DEFAULT_POOL_MAX_CONNECTIONS, }); } return await getBasicDispatcher(baseOptions); } catch (e) { ensureError(e); throw new DispatcherError(e.message, e); } } export async function getTestDispatcher( options: { timeout?: number; } = {}, ): Promise { if (undici === undefined) { undici = await import("undici"); } const baseOptions = getBaseDispatcherOptions(options.timeout, true); return new undici.MockAgent(baseOptions); } /** * Determines whether a proxy should be used for a given url. * * @param url The url to check. * @returns `true` if a proxy should be used for the url, `false` otherwise. */ export function shouldUseProxy(url: string): boolean { const { hostname } = new URL(url); const noProxy = process.env.NO_PROXY; if (hostname === "localhost" || hostname === "127.0.0.1" || noProxy === "*") { return false; } if (noProxy !== undefined && noProxy !== "") { const noProxySet = new Set(noProxy.split(",")); if (noProxySet.has(hostname)) { return false; } } return true; } /** * Determines whether an absolute url is valid. * * @param url The url to check. * @returns `true` if the url is valid, `false` otherwise. */ export function isValidUrl(url: string): boolean { try { new URL(url); return true; } catch { return false; } } /** * Returns the proxy URL from environment variables based on the target URL. * For HTTPS URLs, checks `https_proxy` then `HTTPS_PROXY`. * For HTTP URLs, checks `http_proxy` then `HTTP_PROXY`. * Falls back to the other protocol's proxy if none found. * * @param url The target URL to determine proxy for. * @returns The proxy URL, or `undefined` if none are set. */ export function getProxyUrl(url: string): string | undefined { const { protocol } = new URL(url); if (protocol === "https:") { return ( process.env.https_proxy ?? process.env.HTTPS_PROXY ?? process.env.http_proxy ?? process.env.HTTP_PROXY ); } else if (protocol === "http:") { return ( process.env.http_proxy ?? process.env.HTTP_PROXY ?? process.env.https_proxy ?? process.env.HTTPS_PROXY ); } return undefined; } export { ConnectionRefusedError, DispatcherError, DownloadError, RequestError, RequestTimeoutError, ResponseStatusCodeError, } from "./errors/request.js";