Files
pnpm/network/fetch/src/fetchFromRegistry.ts
Zoltan Kochan 6c480a4375 perf: replace node-fetch with undici (#10537)
Replace node-fetch with native undici for HTTP requests throughout pnpm.

Key changes:
- Replace node-fetch with undici's fetch() and dispatcher system
- Replace @pnpm/network.agent with a new dispatcher module in @pnpm/network.fetch
- Cache dispatchers via LRU cache keyed by connection parameters
- Handle proxies via undici ProxyAgent instead of http/https-proxy-agent
- Convert test mocking from nock to undici MockAgent where applicable
- Add minimatch@9 override to fix ESM incompatibility with brace-expansion
2026-03-29 12:44:00 +02:00

119 lines
3.5 KiB
TypeScript

import { URL } from 'node:url'
import type { FetchFromRegistry } from '@pnpm/fetching.types'
import type { SslConfig } from '@pnpm/types'
import { type DispatcherOptions, getDispatcher } from './dispatcher.js'
import { fetch, isRedirect, type RequestInit } from './fetch.js'
const USER_AGENT = 'pnpm' // or maybe make it `${pkg.name}/${pkg.version} (+https://npm.im/${pkg.name})`
const FULL_DOC = 'application/json'
const ACCEPT_FULL_DOC = `${FULL_DOC}; q=1.0, */*`
const ABBREVIATED_DOC = 'application/vnd.npm.install-v1+json'
const ACCEPT_ABBREVIATED_DOC = `${ABBREVIATED_DOC}; q=1.0, ${FULL_DOC}; q=0.8, */*`
const MAX_FOLLOWED_REDIRECTS = 20
export interface FetchWithDispatcherOptions extends RequestInit {
dispatcherOptions: DispatcherOptions
}
export function fetchWithDispatcher (url: string | URL, opts: FetchWithDispatcherOptions): Promise<Response> {
const dispatcher = getDispatcher(url.toString(), {
...opts.dispatcherOptions,
strictSsl: opts.dispatcherOptions.strictSsl ?? true,
})
return fetch(url, {
...opts,
dispatcher,
})
}
export type { DispatcherOptions }
export interface CreateFetchFromRegistryOptions extends DispatcherOptions {
userAgent?: string
sslConfigs?: Record<string, SslConfig>
}
export function createFetchFromRegistry (defaultOpts: CreateFetchFromRegistryOptions): FetchFromRegistry {
return async (url, opts): Promise<Response> => {
const headers: Record<string, string> = {
'user-agent': USER_AGENT,
...getHeaders({
auth: opts?.authHeaderValue,
fullMetadata: opts?.fullMetadata,
userAgent: defaultOpts.userAgent,
}),
}
let redirects = 0
let urlObject = new URL(url)
const originalHost = urlObject.host
/* eslint-disable no-await-in-loop */
while (true) {
const dispatcherOptions: DispatcherOptions = {
...defaultOpts,
...opts,
strictSsl: defaultOpts.strictSsl ?? true,
clientCertificates: defaultOpts.sslConfigs,
}
const response = await fetchWithDispatcher(urlObject, {
dispatcherOptions,
// if verifying integrity, native fetch must not decompress
headers,
redirect: 'manual',
retry: opts?.retry,
timeout: opts?.timeout ?? 60000,
})
if (!isRedirect(response.status) || redirects >= MAX_FOLLOWED_REDIRECTS) {
return response
}
redirects++
// This is a workaround to remove authorization headers on redirect.
// Related pnpm issue: https://github.com/pnpm/pnpm/issues/1815
urlObject = resolveRedirectUrl(response, urlObject)
if (!headers['authorization'] || originalHost === urlObject.host) continue
delete headers.authorization
}
/* eslint-enable no-await-in-loop */
}
}
interface Headers {
accept: string
authorization?: string
'user-agent'?: string
}
function getHeaders (
opts: {
auth?: string
fullMetadata?: boolean
userAgent?: string
}
): Headers {
const headers: { accept: string, authorization?: string, 'user-agent'?: string } = {
accept: opts.fullMetadata === true ? ACCEPT_FULL_DOC : ACCEPT_ABBREVIATED_DOC,
}
if (opts.auth) {
headers['authorization'] = opts.auth
}
if (opts.userAgent) {
headers['user-agent'] = opts.userAgent
}
return headers
}
function resolveRedirectUrl (response: Response, currentUrl: URL): URL {
const location = response.headers.get('location')
if (!location) {
throw new Error(`Redirect location header missing for ${currentUrl.toString()}`)
}
return new URL(location, currentUrl)
}