UNPKG

pnpm

Version:

Fast, disk space efficient package manager

164 lines (163 loc) • 7.08 kB
import { createHash } from 'node:crypto'; import { createWriteStream } from 'node:fs'; import http from 'node:http'; import { pipeline } from 'node:stream/promises'; const ABBREVIATED_PACKUMENT = 'application/vnd.npm.install-v1+json'; export const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; /** The registry npx/npm is configured with, so a mirror stays a mirror. */ export function registryFromEnv() { return normalizeRegistry(process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY ?? DEFAULT_REGISTRY); } /** * A registry URL that can be used as a base for a relative path. * * `new URL('pkg', 'https://mirror.example.com/npm')` drops the last segment, * so a registry served from a subpath needs its trailing slash to survive. */ export function normalizeRegistry(registry) { return registry.endsWith('/') ? registry : `${registry}/`; } const PROXY_VARS = ['https_proxy', 'HTTPS_PROXY', 'http_proxy', 'HTTP_PROXY']; /** * Routes every request made until the returned function is called through the * proxy the environment names in `HTTPS_PROXY`/`HTTP_PROXY`, honouring * `NO_PROXY`. A no-op when the environment names none. * * `fetch` reads those variables only when Node was started with * `NODE_USE_ENV_PROXY=1`, which nothing that embeds this package controls: * Corepack starts the process that runs pnpm's launcher. On a network that * allows no other route out, the download then fails with a bare * `fetch failed`, while everything before it went through the proxy fine, * Corepack's own download of the `pnpm` package included. * * Node before 24.14 has no way to apply the proxy after startup, so the * request goes direct there, as it always has. * * The setting is process-global, so overlapping downloads share one * activation: the first call applies the proxy and the last of the returned * functions to run puts the agents back, never one in the middle. */ export function useProxyFromEnv() { if (!PROXY_VARS.some((name) => process.env[name])) return () => { }; const { setGlobalProxyFromEnv } = http; if (setGlobalProxyFromEnv == null) return () => { }; // Counted only once the activation took: a call that threw on a malformed // proxy URL holds nothing to release, and a retry after a fix has to activate. if (proxyUsers === 0) restoreProxy = setGlobalProxyFromEnv(process.env); proxyUsers++; let released = false; return () => { if (released) return; released = true; if (--proxyUsers === 0) restoreProxy(); }; } let proxyUsers = 0; let restoreProxy = () => { }; // Node's fetch applies no timeout of its own: a registry that accepts the // connection and then stalls would hang the install with no output. Metadata is // small and quick; the tarball budget has to survive a slow connection carrying // a ~150 MB download. const METADATA_TIMEOUT_MS = 30_000; const TARBALL_TIMEOUT_MS = 15 * 60_000; export async function fetchPackument(registry, pkgName, headers) { return fetchJson(new URL(pkgName, registry), ABBREVIATED_PACKUMENT, headers); } export async function fetchVersionMeta(registry, pkgName, version, headers) { return fetchJson(new URL(`${pkgName}/${version}`, registry), 'application/json', headers); } /** * Streams `meta.dist.tarball` to `dest`, verifying the checksum the registry * published for it. A mismatch removes nothing — the caller discards the whole * temporary directory. * * `registry` re-hosts a tarball URL that points at npm onto that registry, so a * mirror that answered the metadata request serves the download too; `headers` * travel only to the registry's own origin, never to a download host it names. */ export async function downloadTarball(meta, dest, opts = {}) { const url = tarballUrl(meta, opts.registry); const response = await request(url, undefined, TARBALL_TIMEOUT_MS, headersFor(url, opts)); const [algorithm, expected] = checksum(meta); const hash = createHash(algorithm); const body = response.body; await pipeline(async function* () { for await (const chunk of body) { hash.update(chunk); yield chunk; } }, createWriteStream(dest)); const actual = hash.digest('base64'); if (actual !== expected) { throw new Error(`The download from ${url.href} does not match the checksum the npm registry published for it. Refusing to install.`); } } /** * Where to download `meta`'s tarball from. * * Registries that proxy npm hand back npm's own URL. Following it would leave * the mirror the metadata came from — for an air-gapped one, it would not * resolve at all — so the path is re-hosted onto `registry`. Matched by origin, * so a host that merely starts with npm's is left alone. */ export function tarballUrl(meta, registry) { const url = new URL(meta.dist.tarball); if (registry == null || url.origin !== new URL(DEFAULT_REGISTRY).origin) return url; return new URL(`${url.pathname.replace(/^\//, '')}${url.search}`, normalizeRegistry(registry)); } function headersFor(url, opts) { if (opts.headers == null || opts.registry == null) return undefined; return url.origin === new URL(opts.registry).origin ? opts.headers : undefined; } /** * The algorithm and digest to check a tarball against. * * `integrity` is an SRI string, which may hold several space-separated entries; * the first is used. Only the digest that the registry signature covers is * accepted — `shasum` is SHA-1, and a package without `integrity` has already * been refused before any download starts. */ function checksum(meta) { const entry = meta.dist.integrity?.trim().split(/\s+/)[0]; if (!entry) { throw new Error(`The registry published no checksum for ${meta.dist.tarball}, so it cannot be verified.`); } const separator = entry.indexOf('-'); if (separator === -1) { throw new Error(`The registry published an unreadable checksum for ${meta.dist.tarball}: ${entry}`); } return [entry.slice(0, separator), entry.slice(separator + 1)]; } async function fetchJson(url, accept, headers) { const response = await request(url, accept, METADATA_TIMEOUT_MS, headers); return await response.json(); } async function request(url, accept, timeoutMs, headers) { let response; try { response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), headers: { ...headers, ...(accept ? { accept } : {}) }, }); } catch (err) { const reason = err.name === 'TimeoutError' ? `timed out after ${Math.round(timeoutMs / 1000)}s` : err.message; throw new Error(`Could not reach ${url.href}: ${reason}`, { cause: err }); } if (!response.ok) { throw new Error(`Could not download ${url.href}: ${response.status} ${response.statusText}`); } if (response.body == null) { throw new Error(`Empty response from ${url.href}`); } return response; }