Skip to content

feat(vfs-bundle): add @exodus/stasis/vfs-bundle — static bundles from a pnpm or yarn 1 lockfile through an in-memory node_modules - #189

Merged
ChALkeR merged 1 commit into
mainfrom
claude/stasis-pnpm-lib
Oct 1, 2026
Merged

ChALkeR merged 1 commit into
mainfrom
claude/stasis-pnpm-lib

Conversation

@exo-nikita

@exo-nikita exo-nikita commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

What

@exodus/stasis/vfs-bundle builds a JS/TS bundle and its companion lockfile from a project's lockfile alone:

  • No install and no package script ever runs.
  • The project is held in a @preventive/vfs Vfs, which is only read. Nothing is read from disk or written there but tarballs.

packageManager says which install to reproduce:

packageManager Lockfile Install reproduced Version
'pnpm' pnpm-lock.yaml pnpm install --frozen-lockfile --ignore-scripts, pnpm's isolated layout pnpm 10, 11, or 12 from 12.8.1
'yarn1' yarn.lock yarn install --frozen-lockfile --ignore-scripts, yarn's hoisted layout yarn 1.22

The version is packageManagerVersion if given, which has to match packageManager in the root package.json where that pins one. Otherwise it's the pinned one, else pnpm 10.33.4 or yarn 1.22.22.

This is the library half of #172, without the stasis bundle --pnpm CLI flag and without doc/pnpm.md. Plain stasis bundle and the disk-based JS API in @exodus/stasis/cmd/bundle (buildBundle, bundleCommand) produce the same output as on main.

import { Vfs, buildVfsBundle, loadNodeModules, createVfsHost, setCacheDir } from '@exodus/stasis/vfs-bundle'

setCacheDir(dir) // optional; unset, tarballs are fetched every time and nothing is written
const project = new Vfs() // package.json, pnpm-lock.yaml or yarn.lock, src/…
const { bundle, lockfile, stats } = await buildVfsBundle({
  vfs: project, packageManager: 'yarn1', entries: ['src/index.js'], scope: 'full',
  repo: { github: 'owner/name', directory: 'packages/app', commit }, // optional, informational
})
const { root, vfs, projects, host, packageManagerVersion } = await loadNodeModules({ vfs: project, packageManager: 'pnpm' })

buildVfsBundle

  • Takes buildBundle's JS options (entries, scope, conditions, mainFields, metro/platforms, jsx, flow, typescript, tsconfig, resources, packageJSON), plus vfs, packageManager, cwd (a path in the Vfs, / by default) and packageManagerVersion.
  • Puts the informational repo of Bundle: add informational repo: { github, directory, commit } field #190/Detect and record the informational bundle repo (github, directory, commit) #174 on the Bundle:
    • it's detected in the Vfs as stasis bundle detects it on disk: .git, else a package.json repository;
    • a repo passed as { github, directory, commit } overrides that. It's checked as the Bundle constructor checks it, before anything is fetched;
    • it's never written to the lockfile.
  • Returns a Bundle and its Lockfile, whose serialize() output is what stasis bundle writes over a real install.
  • Checks its entries before any tarball is fetched.
  • Reads no EXODUS_STASIS_* setting, whatever env a caller passes.
  • metroResolver is refused, because the project's metro-resolver reads node_modules on disk.

loadNodeModules returns:

  • the tree, in a new Vfs whose / stands for root, the lockfile's directory;
  • the projects found (pnpm's workspace projects, or yarn's workspaces);
  • deptree's counts;
  • the package manager and version reproduced;
  • the host reading the project through the tree. It's a snapshot: a change to the project's Vfs after the call isn't seen.

createVfsHost(vfs) is a host over a Vfs alone. It reads the Vfs as it is at each call, and resolves relative paths from its /.

setCacheDir is @preventive/upstream's. Tarballs are also looked up in npm's cache and ~/.audit before the registry, read-only and integrity-checked.

How

  1. The tree. @preventive/deptree lays it out into a Vfs of its own.
  2. What deptree reads. Through a read-only view of the project, it reads what the package manager reads:
    • the lockfile;
    • the package.json of every project it finds (pnpm-workspace.yaml's packages, or the root's workspaces);
    • its settings files: for pnpm, the patches and local directories they name, pnpm-workspace.yaml and .npmrc; for yarn, .yarnrc and .npmrc.
  3. Tarballs come from registry.npmjs.org (yarn's mirror is taken for it) and are held to the lockfile's integrity.
  4. The bundle. The scan, Node's resolution algorithm, tsconfig discovery and extends, the State that materializes the bundle, and repo detection all read the project through a host with the tree in front of it:
    • each project's node_modules (yarn's workspaces' included) comes from the tree alone;
    • a vendored node_modules elsewhere under the root is read as it is;
    • no node_modules above the root is read, except the directories on the way to the root, so a project can sit under one.
  5. The working directory is taken as a real path, like every file the scan reaches, so a cwd named through a symlink works.
  6. Errors. A refusal of the lockfile, or of pnpm-workspace.yaml, names the file, whichever @preventive/lockfile copy raised it: stasis's alpha.3, or deptree's alpha.2.

Files

  • @exodus/stasis-core:
    • new src/host.js (@exodus/stasis-core/host): the disk host;
    • State reads through a host option (root discovery, stasis.* probes, recorded files, and detectRepo), the disk host by default. readFileSyncMaybe and detectRepo take that host. State joins the live registry only as the preload or to claim writes;
    • a State built with claim: false, as the static build's is, claims no write target. So a bundleFile that the project's stasis.config.json names is no write target of stasis bundle, buildBundle or buildVfsBundle, and building twice in one process no longer fails as already claimed;
    • Config takes the env it reads (process.env by default), and canonicalizes its write targets through the host rather than the real disk;
    • the bundle-util.js/util.js readers take a host.
  • @exodus/stasis:
    • new src/vfs-bundle.js (@exodus/stasis/vfs-bundle): the export alone;
    • new src/vfs-bundle/tree.js: the trees per package manager, and the host over a Vfs, alone or in front of the project. It's loaded only by the export and buildVfsBundle, so plain stasis bundle never loads deptree. One project host serves a whole build;
    • new src/resolve-node.js: Node's CJS resolution over a host;
    • host threaded through scan.js, resolve-fields.js, resolve-typescript.js and cmd/bundle.js's JS builders, the disk host by default;
    • buildVfsBundle in cmd/bundle.js.
  • Dependencies, pinned exactly while they are prereleases: @preventive/deptree 1.0.0-alpha.3 and @preventive/vfs 1.0.0-beta.2. @preventive/lockfile (alpha.3) and @preventive/upstream (alpha.2) stay as on main. deptree still pins @preventive/lockfile alpha.2, so that one is installed beside it.
  • Fixtures and CI:
    • tests/fixtures/pnpm-bundle and the new tests/fixtures/yarn1-bundle are one package set (86 registry packages) under each manager;
    • CI installs both: pnpm, and yarn@1.22.22 through npx;
    • the job timeout goes from 2 to 3 minutes. The jobs now take about 1m50s–2m.

Verification

  • Byte-identical to a real install, for both package managers. From a Vfs holding each fixture, buildVfsBundle's bundle and lockfile equal plain stasis bundle over a real pnpm install / yarn install. This holds for the default options, packageJSON, mainFields, metro with platforms, and node_modules scope. yarn's tree covers its nested copies, such as send/node_modules/debug/node_modules/ms.
  • Interoperates with the real install, for both. The bundle loads under stasis run --bundle=load, and its lockfile verifies under --lock=frozen.
  • Nothing read from disk but tarballs, for both. A subprocess wraps every node:fs read API before loading stasis. During a build, the only paths it reads are cached tarballs: repo detection included, it reads the Vfs, not the disk.
  • The cache and integrity:
    • a bogus lockfile integrity fails closed (both managers);
    • a tarball URL off the registry is refused (both);
    • a second build is served from the cache, and a corrupted cached tarball aborts it and is left in place (pnpm).
  • What the caller passes is respected:
    • the caller's Vfs is left unwritten, and one Vfs can be built from repeatedly, even when its stasis.config.json names a bundleFile;
    • a tampered installed node_modules in the project Vfs doesn't reach the bundle (both);
    • EXODUS_STASIS_BUNDLE=add in the environment is ignored, including with env: undefined or env: process.env passed;
    • the live State registry doesn't grow;
    • a cwd named through a symlink builds;
    • a project under a node_modules directory builds, with that directory's other packages masked;
    • repo is detected from the Vfs's package.json repository, with or without mainFields, and overridden by one passed. It's never on the lockfile, and an invalid one is refused before the tree is laid out.
  • pnpm 12: 12.8.1 lays out a tree, and 12.0.0 is refused, as deptree builds pnpm 12 only from 12.8.1 on.
  • The hosts:
    • createVfsHost sees a file written after it was made, and reads relative paths from the Vfs's /;
    • the tree host masks and serves the zones above.
  • The disk path is unchanged. Plain stasis bundle is byte-identical to main's:
    • of an installed fixture, with and without --mainFields, and with --metro --platforms=ios,android --package-json;
    • of this repository, with its detected repo.
  • The resolver matches require.resolve on every parity case, hits and error codes.
  • Offline tests cover:
    • pnpm: version selection (pnpm 12 included), workspace projects, patch precedence, link:/file: overrides, lockfile v6 and the env-only document;
    • yarn: version selection and pins, workspaces linked, the working-directory rule, named lockfile refusals, .yarnrc refusals;
    • both: packageManager validation; the Vfs host (store links, ELOOP, modes, conditions, trailing slashes); tsconfig extends through the tree.
  • Clean runs. The parity tests remove their temp dirs and restore the environment they change.
  • The full suite passes (2100 of 2103, 3 skipped), and lint is clean.

Not supported (fails closed)

  • pnpm:
    • pnpm 9 and older, and pnpm 12 before 12.8.1; lockfiles older than v9
    • registries other than registry.npmjs.org, and file: tarballs
    • git dependencies, injected dependencies, and a file: directory dependency no override names
    • pnpmfiles and packageExtensions
    • node-linker=hoisted/pnp
    • an importer packages doesn't take, and a workspace project found through a symlink
  • yarn:
    • yarn 2 and later
    • a package from anywhere but npm's registry (or yarn's mirror of it): git, tarball URLs, file:/link:
    • bundled dependencies, nohoist, Plug'n'Play
    • .yarnrc/.npmrc settings that change what yarn installs
  • Both:
    • a lockfile not up to date with a package.json
    • a packageManager pin other than the one reproduced
    • a working directory in a package that is none of the lockfile's projects
    • Windows
    • metroResolver

🤖 Generated with Claude Code

https://claude.ai/code/session_01KPSLumcb6Rdo4xXL5s5CpE

@exo-nikita
exo-nikita force-pushed the claude/stasis-pnpm-lib branch from 0da81c3 to 00503be Compare October 1, 2026 11:39
@exo-nikita exo-nikita changed the title feat(pnpm): add @exodus/stasis/pnpm — static bundles from pnpm-lock.yaml through an in-memory node_modules feat(deptree): add @exodus/stasis/deptree — static bundles from a pnpm or yarn 1 lockfile through an in-memory node_modules Oct 1, 2026
@exo-nikita
exo-nikita force-pushed the claude/stasis-pnpm-lib branch from 00503be to 80baeec Compare October 1, 2026 11:50
@exo-nikita exo-nikita changed the title feat(deptree): add @exodus/stasis/deptree — static bundles from a pnpm or yarn 1 lockfile through an in-memory node_modules feat(vfs-bundle): add @exodus/stasis/vfs-bundle — static bundles from a pnpm or yarn 1 lockfile through an in-memory node_modules Oct 1, 2026
@exo-nikita
exo-nikita force-pushed the claude/stasis-pnpm-lib branch 2 times, most recently from 9ac0a15 to fb0c320 Compare October 1, 2026 13:13
…rom a pnpm or yarn 1 lockfile through an in-memory node_modules

`@exodus/stasis/vfs-bundle` builds a JS/TS bundle and its companion lockfile from a project's
lockfile alone, over the project held in a @preventive/vfs Vfs, which is only read: no install, no
package script run, and nothing read from disk or written there but tarballs. `packageManager` says
whose: 'pnpm' lays out the tree `pnpm install --frozen-lockfile --ignore-scripts` makes from
pnpm-lock.yaml, with pnpm 10, 11 or 12; 'yarn1' the one `yarn install --frozen-lockfile
--ignore-scripts` makes from yarn.lock, with yarn 1.22. The version is `packageManagerVersion` if
given, which has to be the one the root package.json's packageManager pins where it pins one, else
that one, else pnpm 10.33.4 or yarn 1.22.22.

@preventive/deptree lays the tree out into a Vfs of its own, reading through a read-only view of
the project what the package manager reads: the lockfile, the package.json of every project it
finds (pnpm-workspace.yaml's packages, or the root's workspaces), and its settings files (the
patches and local directories pnpm's name, pnpm-workspace.yaml and .npmrc; .yarnrc and .npmrc).
Every tarball is fetched from registry.npmjs.org and held to the lockfile's integrity, cached only
where setCacheDir says. The scan, Node's resolution algorithm and the State that materializes the
bundle then read the project through a host with the tree in front of it: each project's
node_modules served from the tree alone, none above the root read. The result is byte-identical
to plain `stasis bundle` over a real install of the same lockfile.

buildVfsBundle takes buildBundle's JS options with `vfs`, `packageManager`, `cwd` and
`packageManagerVersion`, checks its entries before any tarball is fetched, reads no EXODUS_STASIS_*
setting, and returns the Bundle and its Lockfile, the Bundle carrying `repo`, the informational `{
github, directory, commit }`, where one is given; loadNodeModules returns the tree, deptree's
counts, the package manager reproduced and the host reading the project through it; createVfsHost is
a host over a Vfs alone. A refusal of the lockfile, or of pnpm-workspace.yaml, names the file.

src/vfs-bundle.js is the export and src/vfs-bundle/tree.js the tree and the host over it, loaded
only there and by buildVfsBundle, so plain `stasis bundle` never loads deptree.
@exodus/stasis-core/host is the disk host and src/resolve-node.js the host-based Node resolver;
State (its root discovery included), the core's bundle-util.js/util.js readers and scan.js,
resolve-fields.js, resolve-typescript.js (tsconfig `extends` included) and the JS builders take a
`host`, the disk host by default, with the disk behaviour unchanged; Config takes the `env` it
reads, process.env by default, and canonicalizes its write targets through the host; a State joins
the live registry only as the preload or to claim writes, and one built with `claim: false`, as a
static build's is, claims none, so a bundleFile a project's stasis.config.json names is no write
target of `stasis bundle` or buildBundle; and detectRepo reads through a host too, a State's its
own, so a build over a Vfs detects its repo in the Vfs as `stasis bundle` does on disk.
@preventive/deptree 1.0.0-alpha.3 and @preventive/vfs 1.0.0-beta.2 are pinned exactly while they are
prereleases. CI installs the yarn fixture too, and its job timeout goes to 3 minutes for the
fixtures and the tests over them.

Fails closed on what deptree refuses -- for pnpm: pnpm 9 and pnpm 12 before 12.8.1, registries other
than registry.npmjs.org and `file:` tarballs, git dependencies and `file:` ones no override names,
injected dependencies, pnpmfiles, package extensions, node-linker=hoisted/pnp, lockfiles older than
v9 or not up to date with a project's package.json, an importer pnpm-workspace.yaml's packages don't
take, a project found through a symlink, an integrity that is not a sha512, a patch that does not
apply; for yarn: yarn 2 and later, a package from anywhere but npm's registry (or yarn's mirror of
it), bundled dependencies, nohoist, Plug'n'Play, settings that change what yarn installs, a lockfile
not up to date with a package.json; for both: a packageManager other than the one reproduced,
Windows -- on a package cwd is in (the nearest package.json with a name) that is none of the
lockfile's projects, and on metroResolver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPSLumcb6Rdo4xXL5s5CpE
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants