Tech8 min read

pnpm Store Loader on M4 Mac: virtual paths, native addons, and Vite resolution

IkesanContents

pnpm Store Loader is an experimental feature that reads package JavaScript and JSON directly from pnpm’s shared Content-Addressable Store (CAS).
It was introduced as a new nodeLinker option in pnpm 12.10.0, released on October 6, 2026.

This blog runs on Node.js 22.22.0 and pnpm 10.27.0, which does not meet the requirements for Store Loader.
To test the feature without altering my existing setup, I prepared dedicated binaries and store directories on an Apple M4 Mac, testing small JavaScript dependencies, native addons, and a copy of this blog in sequence.

Loading Packages Directly via Loader Instead of Materializing Files

pnpm’s shared store manages files by content hash in a Content-Addressable Store (CAS), deduplicating identical files across projects.

In the standard isolated mode, files are laid out in the project’s node_modules/.pnpm directory and linked via symlinks, as detailed in pnpm’s symlinked structure documentation.
In contrast, Store Loader’s loaded mode registers a loader hook into the Node.js module resolution process, reading CAS contents through a dependency mapping table.

Adding the official configuration example to pnpm-workspace.yaml causes pnpm run and pnpm exec to preload the loader automatically.
The excluded field in the example tells pnpm to materialize the specified package and its dependencies back into physical files. In a minimal setup without Vitest, this exclusion rule has no effect.

nodeLinker:
  type: loaded
  excluded:
    - vitest

The loader implementation in pnpm 12.11.2 requires Node.js ^24.18.0 || >=26.2.0.
When I invoked the generated loader with local Node.js 22.22.0, it failed immediately with ERR_PNPM_LOADER_UNSUPPORTED_NODE.

Test Environment Setup and Comparison Conditions

I isolated tools and dependencies in dedicated directories without using full virtual machines or OS-level sandboxes.
Node.js was downloaded as official macOS ARM64 binaries, and pnpm was extracted from @pnpm/exe.darwin-arm64 on npm, verified against upstream checksums.

ItemSpecification
HardwareApple M4, 16GB RAM
OSmacOS 26.5.2
pnpm12.11.2
Initial Node.js26.10.0
Native fallback & blog test24.18.0
JavaScript dependenciesnanoid 5.1.5, picocolors 1.1.1
Native dependenciessharp 0.34.5, better-sqlite3 12.6.2
Primary blog toolingAstro 7.1.3, Vitest 4.0.18

The Node.js binary was placed first in PATH for the test process, and pnpm’s store, cache, and state directories were directed to separate paths.
The blog project was copied over, installing fresh dependencies from the existing lockfile without reusing node_modules.

The test code and run logs are published in the pnpm-store-loader directory in LiltingChannelLabo.

JavaScript Resolution and fs.readFileSync() Behavior

Under Node.js 26.10.0, I tested ESM import, CommonJS require(), and JSON imports.
The script generated an 8-character ID with nanoid, invoked picocolors from both module styles, and read the version field from picocolors/package.json.

Next, I passed the path resolved by require.resolve('picocolors/package.json') to fs.readFileSync().

ESM, CommonJS, and JSON imports succeeded across all configurations. However, fs.readFileSync() behavior differed between layout modes.

LayoutFile read on resolved path
Standard isolatedSucceeded
loadedFailed with ENOENT
loaded with picocolors excludedSucceeded

The path returned under loaded was a virtual path like this (hash truncated):

node_modules/.pnpm/.pnpm-loader/<hash>/package.json

The loader reads corresponding contents from the CAS for module imports, but physical files do not exist at that path. fs.existsSync() also returned false.
This matches the constraint described in the official compatibility documentation: Store Loader does not simulate a general-purpose filesystem.

Adding picocolors to excluded changed the resolved path to physical files inside the Global Virtual Store (GVS), a shared directory across projects.
In the generated mapping table, picocolors pointed to a physical directory, while unexcluded nanoid pointed to CAS file metadata.

Running node probe.mjs directly in the loaded directory failed with ERR_MODULE_NOT_FOUND because nanoid could not be resolved without the loader.
Running it via pnpm run probe or supplying the generated loader flag resolved the issue:

node --import ./node_modules/.pnpm/.store-loader.mjs probe.mjs

node_modules Size with Minimal Dependencies

Comparing node_modules for just these two packages showed fewer regular files, but higher total file size:

LayoutRegular filesTotal size
isolated2328,268 bytes
loaded8126,085 bytes

This counts only regular files inside node_modules without following symlinks, excluding shared store disk occupancy.
The bundled loader .store-loader.mjs alone accounted for 115,821 bytes, representing most of the size in this two-package project.

Native Addons and Node.js Version Differences

Next, I prepared a test script generating an 8x8 PNG with sharp and running SELECT 6 * 7 against an in-memory SQLite database via better-sqlite3.
Both packages load compiled native addons from JavaScript.

On Node.js 26.10.0, installation failed even under the standard isolated layout.
better-sqlite3 found no prebuilt binaries for this Node.js version, fell back to building from source, and halted on node-gyp: command not found because build tools were absent in this isolated environment.

sharp also attempted to build from source and failed. The host machine had libvips 8.18.4 installed, and sharp’s documentation notes that it detects system libvips during source builds.
I switched to supported Node.js 24.18.0, bypassed host libvips detection with SHARP_IGNORE_GLOBAL_LIBVIPS=1, and re-evaluated the configurations:

Node.jsLayoutResult
26.10.0isolatedNative dependency installation failed
26.10.0loaded, no exclusionsStopped with ERR_PNPM_CAS_REQUIRES_MATERIALIZATION
26.10.0loaded, sharp and better-sqlite3 excludedNative dependency installation failed
24.18.0isolatedPNG generated, SQL returned 42
24.18.0loaded, no exclusionsStopped with ERR_PNPM_CAS_REQUIRES_MATERIALIZATION
24.18.0loaded, sharp and better-sqlite3 excludedPNG generated, SQL returned 42

Without exclusions, loaded requested physical file materialization during installation.
Excluding both packages under Node.js 24.18.0 produced the same output as the standard layout. However, this materialized all 42 related dependency entries into physical files rather than loading them directly from CAS.

Testing the Standard Layout with the Blog Project

I evaluated the blog copy under Node.js 24.18.0.
The existing onlyBuiltDependencies entries (esbuild, sharp, better-sqlite3) were migrated to allowBuilds under pnpm’s current build approval settings.

allowBuilds:
  esbuild: true
  sharp: true
  better-sqlite3: true
  tesseract.js: false

tesseract.js: false was added during initial installation because unconfigured build scripts halt on ERR_PNPM_IGNORED_BUILDS.

With this configuration, pnpm install --frozen-lockfile, pnpm test, and pnpm build completed successfully.
Vitest passed 51 tests across 5 files. Astro built 5,861 pages including tag indices, and the dev server rendered posts correctly in a browser.

pnpm build took 259.55 seconds of wall time, with peak resident memory at approximately 2.71 GiB via macOS time -l on a machine with active background tasks.

Astro Exclusions and Vite Resolution Limits

Under Store Loader, I initially excluded sharp, better-sqlite3, esbuild, and vitest.
pnpm then requested materialization for tesseract.js as well, because denying build scripts does not exempt a package from CAS loader restrictions:

nodeLinker:
  type: loaded
  excluded:
    - sharp
    - better-sqlite3
    - esbuild
    - vitest
    - tesseract.js

Installation succeeded, but Vitest failed with ENOENT trying to read Astro’s tsconfigs/strict.json through a virtual path before tests started.
Both the dev server and astro build also failed when Vite 8.1.2 attempted to read its own package.json from a virtual path.

Excluding astro materializes its dependency Vite as well. I tested this in an identical copy with the same lockfile.

Installation succeeded across all configurations.

LayoutVitestDEV ServerBuild
isolated51 passedBrowser render OK5,861 pages output
loaded, 5 packages excluded5 files failed reading tsconfigVite package.json ENOENTHalted on same read
loaded, astro also excluded41 passed, 1 file failed loading dependencyHalted resolving astro/configHalted resolving astro/config

After excluding astro, Vitest could not find better-sqlite3 in the remaining test file.
The dev server and build halted because Vite could not resolve astro/config from astro.config.mjs:

Cannot find module 'astro/config' imported from '.../astro.config.mjs'

To isolate whether the module was readable, I ran pnpm exec node resolver-probe.mjs directly in the same directory without Vite.
The script successfully imported defineConfig from astro/config, and better-sqlite3 returned 42:

{"node":"24.18.0","astroConfig":"function","sqliteAnswer":42}

The module existed and was readable through pnpm’s Node loader. The failure occurred during Vite’s application dependency resolution.
The official documentation notes that when bundlers or tools resolve application dependencies outside their own module scope, additional exclusions or resolver integrations may be required.

Inspecting the generated mapping table showed the impact of widening exclusions:

Store Loader configurationDependencies loaded from CASMaterialized dependencies
5 packages excluded381103
astro also excluded134350