feat(dependency-resolver): opt-in support for pnpm's global virtual store - #10567
feat(dependency-resolver): opt-in support for pnpm's global virtual store#10567zkochan wants to merge 10 commits into
Conversation
|
Updated for the pnpm side dropping the directory-resolution change (pnpm/pnpm#13648). The pnpm PR originally moved injected/ It does. Re-verified with an engine carrying only the two remaining pnpm commits:
The single case that did fail was a test fixture, and it was a genuine phantom dependency of our own making: Worth stating plainly: none of the real env components in those workspaces carry The core-aspect mirroring in this branch is a different thing and stays. Bit deliberately does not declare |
`_calculateFinalFileName` stripped the extension from the whole path, so any dot in a parent directory was treated as the extension: `bit doctor --archive /tmp/my.dir/report` wrote `/tmp/my.tar` instead of `/tmp/my.dir/report.tar`. Strip the extension from the last path segment only. Unrelated to the rest of this branch - it surfaced because the e2e temp dir contains a dot, which is why `doctor-infra.e2e.ts` fails locally but passes on CI.
…tore With `enableGlobalVirtualStore` on, dependency directories are created once in a store shared by every workspace and capsule on the machine instead of being re-created inside each `node_modules/.pnpm`. On real workspaces that collapses the project-local virtual store from 20832 to 529 entries (and 4017 to 258), leaving only the injected components. Off by default. Enable per workspace under `teambit.dependencies/dependency-resolver`, machine-wide with `bit config set enable_global_virtual_store true`, or per run with `BIT_ENABLE_GLOBAL_VIRTUAL_STORE` (which is how the e2e suite runs the whole matrix under it). Making it behave identically to the project-local layout needed four fixes: - The core aspects are phantom dependencies of every published env and aspect. Project-locally a package in `node_modules/.pnpm` resolves them by walking up into the workspace's root `node_modules`; nothing in a shared store can walk up into a workspace, so every env failed to load. Mirror the installation's core-aspect links at the root of the store, which every slot in it can reach, and key that store per bit installation so two of them cannot shadow each other's aspects. - A workspace that authors the core aspects (the bit repo) bridges them into the hoisted store only until they compile, then removes the bridge. That is right project-locally and fatal from a shared store, so under it the bridge stays, repointed at the compiled workspace copy. - `getInjectedDirs` reports an injected copy absolute when it cannot be made relative to the lockfile dir - which under a shared store is always. `compiler.task.ts` joined it onto the capsule root regardless, hard-linking build artifacts into a path that does not exist. - Capsules stay on the project-local store. A capsule is a self-contained build sandbox and TypeScript's declaration emit depends on that: it names an inferred type through the package that declares it, which it can only do while that package is inside the capsule. Moving env and aspect packages out made `bit build` of any custom env fail with TS2742. Also adds `patchedDependencies` and `packageExtensions` passthrough. `packageExtensions` is not optional under a shared store: it is the only way to satisfy a phantom dependency of a published package, since the package now lives outside the project and can no longer reach whatever the project hoisted. `bit deps diagnose` and the prune step read the current lockfile instead of assuming `node_modules/.pnpm` holds the packages, and the global store is skipped for `nodeLinker: hoisted`, which uses no virtual store.
Three suites hand-wrote paths that only exist in the project-local virtual store, so they broke under the global one even though the behaviour they check was fine: - `install.e2e` and `node-linker.e2e` asserted on `node_modules/.pnpm` directly. New `helper.fs.getVirtualStoreDirNames()` reads the current lockfile when the directory holds no package dirs, which yields the same names either way. - `dependency-resolver.e2e` verified dependency overrides by reading `node_modules/.pnpm/<depPath>/node_modules/<dep>/package.json`. New `helper.fs.readPackageJsonOfChain(['rimraf','glob','inflight','once'])` walks the chain the way Node resolves it, so it is correct under the project-local, global and hoisted layouts alike. Adds `global-virtual-store.e2e.ts` covering the store itself: deps are not created in the workspace, they resolve out of the shared store, the current lockfile still records them, the component loads and compiles, and a patched dependency is applied to the shared copy.
Documents `enableGlobalVirtualStore`, `globalVirtualStoreDir` and `patchedDependencies`, and adds the script that installs a locally built pnpm engine over the published `@pnpm/napi` - needed until a release carries the options this branch passes (pnpm/pnpm#13648).
`my-react-env.bit-env.ts` opened with `// @bit-no-check`, which turns off Bit's dependency detection for the file. Its imports - notably `@teambit/react.react-env` - therefore never reached the component's generated manifest, so the env only resolved them by reaching packages the workspace had hoisted. That is a phantom dependency, and it stops working the moment the package is not inside the project any more: under the global virtual store the env fails to load with `Cannot find module '@teambit/react.react-env'`. Nothing in this fixture needs dependency detection off - `@ts-nocheck`, which is what the tsconfig scenario actually relies on, stays. None of the real env components in the workspaces this was checked against carry `@bit-no-check`, and they resolve correctly either way.
… global virtual store The core aspects are phantom dependencies of every published env: required without being declared, provided by the running bit installation. Under the project-local virtual store an env resolves them by walking up out of node_modules/.pnpm into the workspace root. Under the global virtual store the env lives in the shared store with no workspace above it, so that fails. Bit worked around this by mirroring the core aspects at the root of the virtual store, which forced a <storeDir>/bit-links/<installationId> root private to one bit installation: no slot reuse across bit versions or with other pnpm projects, a full re-materialization of ~275k directories on every bit upgrade, and nothing to prune stale roots. Use pnpm's documented mechanism for hoisted dependencies in this layout instead. The privately hoisted directory stays project-local at node_modules/.pnpm/node_modules even under the global virtual store, and is reached from a store slot via NODE_PATH. Link the core aspects there and put it on NODE_PATH during bootstrap - pnpm does that for the shims it writes, but bit runs from bvm and loads aspects in its own process. Nothing is written inside the store, so pnpm's shared <storeDir>/links can be used: slots are reused across bit versions and with every other pnpm project, upgrades stay incremental, and pnpm store prune accounts for them. Node ignores NODE_PATH for ESM. Core aspects and published env dists are CommonJS today; @pnpm/plugin-esm-node-path is the escape hatch if that changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… in ESM too NODE_PATH covers only CommonJS - Node ignores it for ESM entirely. Bit supports ESM components, so an ESM package in the shared store could not reach the hoisted directory at all, leaving the workaround covering only half the problem. Register an ESM loader whose resolve hook retries a failed bare specifier against the NODE_PATH entries. Registered in bit's own process via module.register, and exported through NODE_OPTIONS so the processes bit spawns - env build steps, app servers, test workers - inherit it, since they resolve their own dependencies and hit the same wall. The loader and the --import registration are adapted from pnpm's @pnpm/plugin-esm-node-path (MIT), inlined as a data URL so there is no file to ship and no install step to depend on. Skipped when module.register is missing (Node < 20.6); the CommonJS half still applies there. Also generalize the surrounding comments: this is about phantom dependencies as a class, not core aspects specifically. The core aspects are the case that motivated it, but any under-declared package resolves the same way, which is why the whole hoisted directory goes on the path rather than a hand-picked list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8552920 to
8c2f15e
Compare
The global virtual store work needs two engine changes that are now in a released build: the napi surface for `enableGlobalVirtualStore`, `globalVirtualStoreDir`, `packageExtensions` and `patchedDependencies` (pnpm/pnpm#13648), and the fix for `link:` dependencies vanishing from virtual-store slots (pnpm/pnpm#13658). Verified on the released engine: a global-virtual-store install of the opus-5 workspace reuses all 30,766 existing slots (creates none), leaves the project-local `node_modules/.pnpm` empty, and still applies the react-env tsconfig patch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…f-hosted workspaces Two failures surfaced running bit's own repo (a workspace whose node_modules provides the running bit) under the global virtual store; both are general. Built-in packageExtensions. Several published envs' tsconfigs `extends` a package they never declare (`@teambit/react/typescript/tsconfig.json` from the node mocha envs, base-react-env, rspack react-env and react-env 1.x; `@teambit/rspack.envs.react-env/config/tsconfig.json` from cloud-react). TypeScript resolves `extends` with its own resolver from the tsconfig's real location: project-locally the walk up out of node_modules/.pnpm passes the privately hoisted directory, which catches any phantom, so these envs worked by accident; a global-store slot walks up into the store and finds nothing, and `bit status`/`bit compile` fail with "File '@teambit/react/typescript/ tsconfig.json' not found." Ship pnpm's documented remedy for exactly this - packageExtensions declaring the missing dependency - for the known envs, merged under the workspace's own entries so users can cover third-party envs the same way. Extensions only add what a manifest lacks, an unmatched entry is not an error (which is why react-env's can be scoped to @1 - 2.x inlined the config), and none of these envs import their extends-target at runtime, so the added registry copy cannot shadow the host-provided core-aspect singletons. Transition guard. Switching a workspace between the project-local and global layouts relocates every injected component package, rebuilding the top-level directories from source - their compiled dist disappears until the end-of-install compile restores it. Steady-state installs in either layout preserve the top-level directories (verified by sampling them through live installs), so a bit running from this workspace's node_modules survives every install except the layout switch itself, where it loses its own code mid-install and leaves node_modules unusable. Detect the combination up front - current layout read off .modules.yaml's virtualStoreDir, self-hosting detected by realpath of the running module - and refuse with instructions to run the one-time transition from an external bit installation. The guard sits in installComponents, not install(): workspace installs enter there directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tual store The repo's own tsc gate broke once node_modules moved to the global virtual store, all through one mechanism: a published package's .d.ts can no longer reach undeclared packages by walking up from its store slot, where the walk out of node_modules/.pnpm used to catch them. - tsconfig paths: route react/react-dom to the workspace's hoisted @types (the published UI packages peer on react but never declare @types/react, so their props types silently collapsed - 58 of 68 errors), and route @teambit/* through the workspace's node_modules (published .d.ts reference core aspects they never declare; this is the type-layer counterpart of the runtime NODE_PATH bridge). - declare @types/graceful-fs, which run-bit.ts relied on as a phantom. - drop two @ts-expect-error suppressions whose local-vs-published skew the now consistent resolution eliminated, annotate the component-compare query response whose published type lags the GraphQL response (`tests`), and work around the published Tab/ContentTab pair whose members intersect `content` into the unusable `ReactNode & string`. The lockfile carries the built-in packageExtensions (@teambit/react in the phantom-extends envs' slots) and @types/graceful-fs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opt-in support for pnpm's global virtual store: dependency directories are created once in a store shared by every workspace and capsule on the machine, instead of being re-created inside each
node_modules/.pnpm.Draft because of one remaining engine dependency: pnpm/pnpm#13669. The settings surface (pnpm/pnpm#13648) and the
link:slot fix (pnpm/pnpm#13658) are released in@pnpm/napi12.0.0-rc.0, which this branch now pins — but rc.0 still carries a race in slot materialization that #13669 fixes: peer variants of an injected component whose dependency hashes are equal share one slot by design, and the link pass raced one forced import per variant against the same directory (failed to remove existing directory ... prior to swap). The bit repo hits it on every install. Until a release carries it,scripts/link-local-pnpm-engine.shinstalls a locally built engine over the published one.Effect
After a fresh install the project-local virtual store holds nothing but
lock.yamland the hoistednode_modules— every dependency directory, including injected workspace components, lives in the shared store:node_modules/.pnpmentries, no GVSBit uses pnpm's own
<storeDir>/links, so slots are reused across bit versions and shared with every other pnpm project on the machine, upgrades stay incremental, andpnpm store pruneaccounts for them.Migration note: switching an existing workspace over does not remove what the previous project-local store already contains — pnpm does not prune it once the effective virtual store points elsewhere — so the old directories linger until
node_modules/.pnpmis deleted. New workspaces never see them.Turning it on
Off by default. Per workspace under
teambit.dependencies/dependency-resolver, machine-wide withbit config set enable_global_virtual_store true, or per run withBIT_ENABLE_GLOBAL_VIRTUAL_STORE=true— which is how the e2e suite runs the whole matrix under it.What it took to behave identically
A shared store breaks one assumption that runs through Bit: that a package can walk up out of
node_modules/.pnpminto the workspace. Consequences:@teambit/*core aspects as dependencies of the envs and aspects that use them — they have to be the single copy from the running installation, and declaring them would install duplicates. Project-locally they resolved by walking up into the workspace root; nothing in a shared store can. This branch uses pnpm's documented mechanism for hoisted dependencies in this layout: the privately hoisted directory stays project-local atnode_modules/.pnpm/node_moduleseven under the global virtual store, the core aspects are linked there, andbootstrapputs that directory onNODE_PATHbefore any aspect loads. Nothing is written inside the store itself, which is what allows the shared<storeDir>/linksto be used at all.NODE_PATHfor ESM, and bit supports ESM components, so the same hoisted-directory fallback is provided to the ESM resolver via amodule.register()loader (adapted from pnpm's@pnpm/plugin-esm-node-path, MIT), installed at bootstrap and propagated to child processes throughNODE_OPTIONS.tsconfigextendsin published envs. TypeScript resolvesextendswith its own resolver — neitherNODE_PATHnor the ESM loader applies — and several published envs extend a package they never declare (@teambit/react/typescript/tsconfig.jsonfrom the node mocha envs,base-react-env,rspack.envs.react-envandreact-env1.x;rspack.envs.react-env/config/tsconfig.jsonfromcloud-react). Project-locally the walk up out of.pnpmpassed the privately hoisted directory and caught these by accident. Fixed with pnpm's documented remedy: built-inpackageExtensionsdeclaring the missing dependency for the known envs, merged under the workspace's ownpackageExtensionsso users can cover third-party envs the same way. Extensions only add what a manifest lacks, an unmatched entry is not an error (react-env's is scoped to@1— 2.x inlined the config), and none of these envs import their extends-target at runtime, so the added copy cannot shadow the host-provided singletons. (ApatchedDependencies-based fix for react-env 1.x moved to fix(dependency-resolver): patch react-env's tsconfig extends for the global virtual store #10572; it stays blocked until@pnpm/napiexposesallowUnusedPatches.)getInjectedDirsreports an injected copy absolute when it cannot be made relative to the lockfile dir — under a shared store, always.compiler.task.tsjoined it onto the capsule root regardless, hard-linking build artifacts into a path that does not exist. (node-modules-linkerandworkspace-compileralready guarded this; only that one site did not.)bit buildof any custom env fail withTS2742. Workspace installs — the ones users wait on repeatedly — still get the shared store.Self-hosting: a workspace whose node_modules provides the running bit
Both layouts are safe in steady state: repeat installs preserve the top-level package directories the running process resolves from (verified by sampling them through live installs), and the end-of-install compile refreshes the per-variant copies. A layout switch is different — it relocates every injected component package, rebuilding the top-level directories from source, so their compiled
distdisappears mid-install and a bit running from them dies before reaching the compile that would restore it, leavingnode_modulesunusable. The installer now detects the combination (current layout read off.modules.yaml, self-hosting by realpath of the running module) and refuses up front with instructions to run the one-time transition from an external bit installation.With that and the engine fix, the bit repo itself runs under the global virtual store: full install + compile of all 332 components, repeat self-hosted installs in ~50s,
npm run lintgreen. The repo's own tsc needed the type-layer counterpart of the runtime bridges —tsconfig.jsonpathsrouting react typings and@teambit/*through the workspace'snode_modules, since a published package's.d.tscannot reach undeclared@types/core aspects from a store slot either.Phantom dependencies, generally
A shared store makes an under-declared package fail loudly: it lives outside the project, so nothing the project hoisted is on its resolution path any more. That is a real constraint, not a bug to work around, and it applies to pnpm generally rather than to Bit. Every failure this branch hit was a genuine phantom dependency — an env's undeclared
tsconfigbase, a fixture env with detection switched off (@bit-no-checkremoved so the dependency gets declared),@teambit/webpack.webpack-bundlerrequiringevents/without declaring it (covered bypackageExtensions, documented in the aspect docs), and the repo's own undeclared@types/graceful-fs(now declared).Testing
node_modulesinstall + compile, slot reuse verified (a repeat install creates 0 of 30766 slots), core-aspect resolution throughNODE_PATHverified positively and negatively,bit startworking.bit app listbyte-identical to the non-GVS baseline, apps running — against an earlier revision of the core-aspect mechanism.BIT_ENABLE_GLOBAL_VIRTUAL_STORE=truewas last run against an earlier revision (net result then: no GVS-specific failures; thevalidate commandfailures reproduce with the store off). CI re-runs it against the current branch.The first commit (
fix(doctor)) is unrelated to the rest and can be split out — it surfaced because the e2e temp dir contains a dot, which is whydoctor-infra.e2e.tsfails locally but passes on CI.