Ryan Cao 

A Corepack by Any Other Name

6 min

How many HTTP requests do you think Corepack makes when you run corepack use pnpm to use the latest version of pnpm in your project?

Wrong — it’s both one and two. Two, in the sense that it makes two requests; one, because these two requests are completely identical. I came across this little idiosyncrasy while working on Moldau, a version manager for Node.js package managers that is part of my increasingly expansive repertoire of cryptically named developer-oriented tools. For those of you familiar with Node.js, you probably use, or at least know of, the tool that is traditionally employed for this use case: Corepack. In a nutshell, Corepack allows you to use specific versions of package managers for different projects, with the version being specified in the packageManager or devEngines.packageManager field in the package.json of each project; kind of like Nix development environments, or mise, but exclusively for Node.js package managers.

Since Corepack will, sadly, not be distributed in Node.js from Node.js v25 onwards, I thought it might be a fun idea to write a Corepack alternative myself in Rust. I embarked on this journey with bravado, aiming for best-effort compatibility with most of Corepack. But soon enough, I began to realize the great folly with which I had set myself on this task. As I ran into numerous weird compatibility issues and differences in Moldau’s behavior from Corepack, I realized that Corepack’s technical design was much more complicated than I thought.

One of the most interesting choices that Corepack makes is that it uses a hardcoded config file for storing a lot of package metadata. In Node.js, the "bin" field in package.json specifies which files in the package are to be installed as executables for the user to run, and if you download the tarball that the npm registry provides, you can retrieve this data from the package.json in the tarball. With Corepack, though, this data is hardcoded in the config itself. This would an odd choice if Corepack downloads package managers as package tarballs, but as it happens, it does not!

For Yarn, and Yarn only, Corepack downloads a singular JavaScript bundle from repo.yarnpkg.com instead of downloading a tarball from registry.npmjs.org if the major version is greater than or equal to 2. This fascinating behavior is necessitated by the fact that Yarn has a prodigiously confusing versioning scheme. Yarn “classic”, with a major version of less than or equal to 1, is distributed on the npm registry as registry.npmjs.org/yarn; Yarn “modern”, or “berry”, with a major version of greater than or equal to 2, is distributed on repo.yarnpkg.com. In the Yarn 2 release post, Maël Nison, the lead maintainer for Yarn, explains thus:

The yarn package on npm will not change; we will distribute further version [sic] using the new yarn set version command.

Yarn’s original release model relied on keeping the classic version of Yarn (i.e. registry.npmjs.org/yarn) around for all users to install (in the interest of backwards compatibility) while having classic Yarn fetch bundles of modern Yarn to execute from repo.yarnpkg.com if a user requested them. If you wanted to specify a version of Yarn to use in your project, you would set the yarnPath config to the path to the downloaded bundle (which could be checked into your project’s VCS as well).

On March 12, 2020, Corepack was born, written by none other than Maël Nison. Corepack was an elegant solution to the problem of package manager version management; by including the versions in package.jsons and distributing the tool to read these versions alongside Node.js, everyone could easily get on the same page. Since the release of Yarn v3, Yarn’s official documentation has recommended using Corepack rather than installing Yarn globally or using yarn set version; in fact, in Yarn v4, yarn set version defaults to configuring Corepack instead of modifying the yarnPath config property. But an issue remained: Yarn v2 was not distributed anywhere other than repo.yarnpkg.com (as far as I’m aware); consequently, in order to support Yarn v2, Corepack still had to download the bundles from repo.yarnpkg.com, as yarn set version did, if users tried to use modern Yarn. This also made it necessary for Corepack to hardcode the names of executables in its config, since the information was not readily available for the downloaded bundles of Yarn (pun most definitely intended).

Thus, in order to support both classic and modern Yarn, Corepack has two configs for Yarn, differentiated by the SemVer range <2.0.0 and >=2.0.0. This config duplication also extended to pnpm, when in v6.0.0 they renamed their executable files from bin/{pnpm,pnpx}.js to bin/{pnpm,pnpx}.cjs. Since the executable names were hardcoded, Corepack had to create a new config for >=6.0.0 with an updated, hardcoded "bin". And this is where it gets exceedingly interesting; take a look at the following code snippet.

// https://github.com/nodejs/corepack/blob/aefde28a631356bfdec91795d2c60be07dbf5be3/sources/Engine.ts#L409-L415

const versions = await Promise.all(
  Object.keys(definition.ranges).map(async (range) => {
    const packageManagerSpec = definition.ranges[range];
    const registry = corepackUtils.getRegistryFromPackageManagerSpec(packageManagerSpec);

    const versions = await corepackUtils.fetchAvailableVersions(registry);
    return versions.filter((version) =>
      semverUtils.satisfiesWithPrereleases(version, finalDescriptor.range),
    );
  }),
);

Corepack doesn’t choose one of the duplicated configs to fetch from; it fetches all of them and joins the matching versions together! The technical choices that Corepack made it so that it had to obtain information from all the ranges before it could resolve the final version if the user provides a non-exact version, and because the two configs for pnpm differ in nothing other than the "bin" field, Corepack makes two completely identical requests for the same package data if you ask it for a version of pnpm.

For Moldau, I did not want to implement a whole set of code just for downloading Yarn while I could handle npm and pnpm easily through a generic interface to the npm registry. Fortunately, I discovered that versions of Yarn from v3 upwards were also published to an npm package with the name of @yarnpkg/cli-dist; Heroku’s buildpacks and Volta use it for installing Yarn, and Corepack even uses it as a fallback for when the user specifies a custom npm registry URL. All in all, this meant that Yarn was distributed in three locations as far as I knew:

In the interest of the DRY principles and the soundness of my mental faculties, I decided to use the @yarnpkg/cli-dist and yarn packages on the npm registry to install Yarn versions in Moldau; this way, all three supported package managers could share the same code that interacts with registry.npmjs.org. Of course, this came at the cost of not being able to support most Yarn v2 versions; however, usage of Yarn v2 with Corepack is low (as far as I could tell from a cursory GitHub code search), since Corepack only became the preferred installation method with Yarn v3, and it is unlikely that users will choose to use Yarn v2 today.

After implementing the functionality to download a package tarball and unpack it, I ran into yet another issue that was caused by Corepack’s eccentric handling of Yarn. The packageManager key in package.json optionally supports specifying a hash for verifying the integrity of the downloaded package manager; however, with Yarn, the hash that Corepack uses is the hash of the bundle, not the package tarball, since it doesn’t download the tarball in the first place. To resolve this issue, I had to write some Yarn-specific code: when verifying integrity hashes for Yarn, Moldau finds the Yarn executable bundle in the unpacked package and calculates the hash of that bundle, instead of calculating the hash of the tarball as it does with npm and pnpm. The things we do for compatibility.

In general, I am quite satisfied with what I achieved with Moldau. Despite the various hiccups and pitfalls in making Moldau compatible with Corepack’s core functionality, I made several of what are, in my opinion, improvements over Corepack’s design. In order to figure out whether to use @yarnpkg/cli-dist or yarn when resolving Yarn versions, I implemented a heuristic that determines whether modern Yarn or classic Yarn is being requested based on the SemVer version or range provided by the user, so that Moldau only makes requests that are necessary:

let is_classic = self.version.exact().is_some_and(|v| v.major <= 1)
	|| self.version.semver_req().is_some_and(|r| {
		r.comparators.iter().any(|c| match c.op {
			semver::Op::Exact
			| semver::Op::LessEq
			| semver::Op::Tilde
			| semver::Op::Caret => c.major <= 1,
			semver::Op::Less => {
				c.major <= 1
					|| c.major == 2
						&& c.minor.is_none_or(|n| n == 0)
						&& c.patch.is_none_or(|n| n == 0)
			}
			_ => false,
		})
	});

I did not have to hardcode information on where package managers’ executables are in Moldau, since this information could be dynamically read from the "bin" key from the unpacked packages’ package.jsons. I added a nice progress bar when downloading package tarballs, powered by the indicatif crate, and I left out several pieces of functionality that I personally didn’t deem to be useful, such as the Known Good Releases mechanism and auto pin. And, as goes without saying, Moldau is marginally faster than Corepack due to being written in Rust and compiled to native code.

Corepack is a very impressive project, and it’s certainly been an experience going through source code, docs, and blog posts to understand how it works and why it is implemented the way it is. Moldau was born out of these endeavors as something of a Corepack by another name, but I believe it to be much more than that; if you happen to try it out (cargo binstall moldau), I hope you will find that it smells as sweet.

Tagged javascript, rust, terminal

2,047 reads

Licensed under CC BY-NC-SA 4.0

← Previous Ephemeral Permissions Considered Beneficial Mar 29, 2025 Next → Five Years of Design Sep 25, 2025