Skip to main content

Real npm, yarn and pnpm in the browser, and how each one broke the runtime

· 8 min read

Once you are running Node's real lib/ in a tab, the interesting question stops being "can we reimplement npm?" and becomes "why would we?". The package managers are just Node programs. If the runtime underneath them is honest, the real CLIs should run unmodified.

They do. npm, yarn, pnpm and corepack in Vivari are the actual published releases, vendored and executed as-is. But "should run" and "runs" are separated by every Node API each tool happens to touch, and the four of them touch almost disjoint sets. Each one, in turn, walked into a different corner of the runtime and found the wall.

Not a reimplementation

The retired approach was a Turbo-style installer that resolved a lockfile and wrote node_modules itself. It was fine for a demo and wrong for a product: it was not npm, so it did not behave like npm, and every gap between the two was a support burden. The shipped studio boots the real thing instead.

Delivering a real CLI to a tab is a packaging problem. Each package manager is installed at a pinned version on the build host, its file tree is walked, and the whole thing is written into one archive that ships as a static asset under packages/studio/public/vendor/. The archive is a deliberately boring custom format rather than a tarball:

[u32le headerLen][header JSON][file bytes ...] then gzip the whole lot

Two decisions in that one line earned their comments. The format is custom because we control both ends and would rather not meet tar's long-path and GNU-extension edge cases, and npm's @npmcli/* paths are long enough to hit them. And the gzipped output is named *-pack.bin, not *.gz, on purpose:

Static servers (Vite's sirv, many CDNs) treat a .gz file as TRANSFER-encoded and serve it with Content-Encoding: gzip, so the browser transparently decompresses it before our fetch sees it, and our own gunzip then fails on already-decompressed bytes. A neutral extension is served verbatim.

At boot the kernel worker fetches npm-pack.bin, gunzips it with the browser's DecompressionStream, and writes the tree into the virtual filesystem at /usr/lib/node_modules/npm in one batched transfer. A three-line shim lands on PATH at /bin/npm.js and does nothing but require the real bin/npm-cli.js. npm is loaded eagerly because almost every project needs it; yarn, pnpm and corepack are registered as lazy loaders and only fetched the first time you actually spawn them, so the sizes below are costs you opt into.

ToolVersionDeliveredFiles
npm10.9.2~2.8 MB gz~2400
yarn (classic)1.22.22~1.2 MB gz11
pnpm9.15.9~3.7 MB gz~898
corepack0.35.0~0.12 MB gz54

None of that is the hard part. The hard part is that a real CLI calls real Node.

npm: the fidelity pass

npm booted first, and getting npm -v to print 10.9.2 and exit 0 closed three gaps that a reimplementation would never have surfaced, because they are about being Node, not about installing packages. process had to be a genuine EventEmitter (npm's proc-log attaches listeners to it). A dynamic import() inside a CommonJS module had to route through our loader (npm does await import('chalk')). And stdout.write(cb), process.exitCode and a single 'exit' event all had to behave the way npm's exit-handler assumes. Fixing those was less "supporting npm" and more "finishing Node".

Then there is the thing every in-browser runtime has to answer for: native addons. There is no compiler in a tab, and a .node binary could not be loaded if there were, because we run Wasm. But real npm runs a package's install/rebuild lifecycle script, which for a native package is node-gyp rebuild, and a non-zero exit there aborts the entire install. So node-gyp is stubbed to a non-fatal no-op:

To keep installs working we make node-gyp a non-fatal no-op: the build is skipped and the script "succeeds". This mirrors how browser WebContainers handle native deps: the package's JS fallback (or its wasm32-wasi build, auto-selected via optionalDependencies) is what actually loads at runtime.

The rest of npm's needs are environmental. Registry requests go through a fetcher that strips the non-safelisted headers a browser would otherwise preflight and reject; downloads run through an async fetch op so npm's parallel tarball fetches are actually parallel; large writes bypass the shared buffer pool; and npm_config_audit, npm_config_fund and the update-notifier are switched off, with the cache pointed at an OPFS-backed directory so it survives a reload.

yarn: five gaps in one bundle

Yarn classic is trivial to deliver (a bin/yarn.js entry and a ~5 MB webpack cli.js, eleven files total) and instructive to run. It exercises a set of Node internals npm simply never reached, and lighting it up filled five more compatibility gaps, all fixed down in packages/runtime/ where they help every program and not just yarn.

The memorable one is graceful-fs, which yarn bundles through fs-extra. graceful-fs patches fs by subclassing fs.WriteStream with fs$WriteStream.apply(this, arguments), the old prototypal-inheritance move, which throws against a modern class. Alongside it: process.memoryUsage() (yarn's reporter tracks peak memory), and internal/fs/dir for fs.opendir, which yarn trips indirectly because thenify-all runs promisifyAll(fs) over every method it can find. A package manager that reflects over the whole fs module is an excellent conformance test you did not have to write.

pnpm was the one we expected to be hardest, and it was, because it uses the features the others avoid. It drives real worker_threads for fetch and extract. It builds a symlinked node_modules: a content-addressable store plus symlinks into it, which means the virtual filesystem has to implement symlink, readlink and lstat as first-class operations rather than approximations. The store's packages are shared via hard links, so the VFS also grew a real link(2). What it does not get is reflink/copy-on-write, so the prebuilt *.node reflink addons, which only exist for macOS and Windows, are dropped at vendor time rather than shipped as dead weight on a Linux target.

The subtle failure was in how pnpm writes the executables in node_modules/.bin. npm makes them POSIX symlinks to the real .js; pnpm writes a #!/bin/sh cmd-shim that exec node "$basedir/../vite/bin/vite.js" "$@". Our loader cannot run a shell script, so without help it hands that shell wrapper to the JS compiler and gets SyntaxError: missing ) after argument list the first time you run a pnpm-installed binary. The fix is a small, unit-tested unwrapper that parses the target .js out of the shell shim before runMain execs it. The only genuinely missing runtime primitive was util.types.isBoxedPrimitive, which pnpm's JSON path uses; it went in with the rest of the boxed-primitive and typed-array predicates.

corepack: a package manager for package managers

corepack is the odd one out, because it is not a package manager at all. It is a version manager: it reads a project's packageManager field, downloads that exact yarn or pnpm release, verifies it, and execs it. So it gets only a /bin/corepack.js shim and deliberately leaves the direct npm/yarn/pnpm shims alone: those stay the defaults, and corepack is the extra "run the project-pinned version" path.

Its download-then-extract-then-exec pipeline surfaced five more gaps, again fixed generically: require('module').runMain, which corepack uses to exec the downloaded manager in-process; Readable.fromWeb, so it can stream the tarball out of the global fetch() response body; WHATWG stream readers whose read()/cancel() promises properly ref the event loop, so a download does not race the loop to exit; and crypto.Hash extending stream.Writable, so the idiomatic stream.pipe(createHash(algo)) works and the sha512 integrity check passes. The one thing our crypto layer cannot do is corepack's registry ECDSA signature check (there is no crypto.verify), so the shell sets COREPACK_INTEGRITY_KEYS=0, which is corepack's own supported escape hatch. The tarball's sha512 integrity is still checked.

The pattern, again

None of these four required code that knows anything about installing packages. npm needed process to be a real EventEmitter; yarn needed graceful-fs to be able to subclass fs.WriteStream; pnpm needed symlinks and hard links to be real filesystem operations; corepack needed WHATWG streams to ref the loop. Every one of those fixes lives in the runtime, not in a shim, so it is there for the next program too, which is exactly why the next thing to run took less work than this did.

That is the whole bet behind running Node's real source: you do not implement the tools, you implement the platform, and then the tools run because they were always just programs. The argument is spelled out in more detail in the architecture docs.