Real npm, yarn and pnpm in the browser, and how each one broke the runtime
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
.gzfile as TRANSFER-encoded and serve it withContent-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.
| Tool | Version | Delivered | Files |
|---|---|---|---|
| npm | 10.9.2 | ~2.8 MB gz | ~2400 |
| yarn (classic) | 1.22.22 | ~1.2 MB gz | 11 |
| pnpm | 9.15.9 | ~3.7 MB gz | ~898 |
| corepack | 0.35.0 | ~0.12 MB gz | 54 |
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: worker threads, symlinks, and shell shims
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.