depvault
v0.1.2
Published
DepVault — a global content-addressable dependency vault for Node. Eliminates per-project node_modules and resolves modules at runtime via Node's loader hooks.
Maintainers
Readme
DepVault
A global vault for Node dependencies. Projects keep nothing locally — packages live once in a content-addressable store and resolve at runtime through Node's loader hooks.
project/
├── package.json
├── depvault-lock.json ← exact-version + integrity manifest
├── app.js
└── (no node_modules anywhere)
~/.depvault/store/ ← shared across every project on the machine
└── cas/sha512/<2>/<rest>/{package.json, lib/, ...}When app.js does require('react'), DepVault intercepts the call, walks
depvault-lock.json to find the right version for this importer, and points
Node directly at ~/.depvault/store/cas/sha512/.../react/. No copying. No
hardlinks per project. No nested node_modules trees. The vault is the
single source of truth.
Table of contents
- Why
- Install
- Quick start
- Architecture
- CLI reference
- Configuration
- Framework integration
- Performance
- Security
- Limitations
- FAQ
- Roadmap
- Publishing (for maintainers)
- Contributing
- License
Why
| Concern | Traditional node_modules | pnpm | DepVault |
|---|---|---|---|
| Disk per project | full copy of every dep | hardlinks to global store | zero per-project files |
| Cold install | minutes | tens of seconds | seconds (no per-project copy step) |
| Cross-project dedupe | none | hardlinks (per-file) | direct shared paths |
| Editor "go to definition" | works | works | works (resolves into the vault path) |
| Native modules | work | work | flagged in MVP, rebuild flow on roadmap |
| Lockfile portability | yes (package-lock.json) | yes (pnpm-lock.yaml) | yes (depvault-lock.json) |
| Lifecycle scripts | run automatically | run automatically | disabled by default (security stance) |
The headline number from scripts/bench.mjs on a lodash + chalk project:
project disk: 3.3 KiB (DepVault) vs 1.4 MiB (npm) — 99.8% smaller per project
warm install: 90 ms (DepVault) vs 489 ms (npm) — 5× fasterFor a developer machine with 100 Node projects sharing common deps, this is the difference between a 50 GB and a 5 GB working set.
Install
DepVault ships as a regular npm package. Pick one path:
From the npm registry (recommended)
npm install -g depvaultAfter install you'll have two commands on your PATH: depvault (canonical)
and dv (shorthand). They're aliases for the same binary.
depvault --version
dv --versionFrom a tarball (no clone, no build)
# from a local tarball produced by `npm pack`
npm install -g ./depvault-0.1.0.tgz
# or from any URL hosting it
npm install -g https://your-host.example.com/depvault-0.1.0.tgzFrom a git URL (no registry, no tarball)
npm install -g git+https://github.com/estd20xx/ndve.git
# or pin to a tag
npm install -g git+https://github.com/estd20xx/ndve.git#v0.1.0npm runs the package's prepare script after a git install, so the
TypeScript build happens automatically.
Update / uninstall
npm install -g depvault@latest
npm uninstall -g depvaultQuick start
Once depvault is on your PATH:
cd ~/my-app # has package.json, no node_modules
depvault install # populates the global vault, writes depvault-lock.json
depvault run app.js # runs without node_modulesOr use the dv shorthand:
dv install
dv run app.jsTry the bundled examples:
# minimal CJS + ESM demo
cd examples/hello-app
dv install
dv run app.js # CJS
dv run app.mjs # ESM# real HTTP server with 72 transitive deps
cd examples/express-app
dv install
dv run server.jsOr build a 30-second demo from scratch:
mkdir my-test && cd my-test
echo '{"name":"t","version":"1.0.0","dependencies":{"chalk":"^4"}}' > package.json
echo 'console.log(require("chalk").green("hello from DepVault"));' > app.js
dv install
dv run app.js
ls # only package.json, app.js, depvault-lock.json — no node_modulesArchitecture
Component diagram
┌──────────────────────────────────┐
│ npm registry │
│ (packuments + .tgz tarballs) │
└──────────────┬───────────────────┘
│ HTTPS
┌──────────────────────────────────────┴───────────────────────────────────┐
│ depvault install │
│ │
│ package.json │
│ │ │
│ ▼ │
│ ┌─────────────────┐ packument ┌────────────────────────┐ │
│ │ RegistryClient │ ───────────────►│ resolveDependencyGraph │ │
│ │ (cache, fetch) │ │ • semver max-satisfy │ │
│ └─────────────────┘ │ • version reuse │ │
│ ▲ │ • optional/peer aware │ │
│ │ tarballs │ • os/cpu gating │ │
│ │ └───────────┬────────────┘ │
│ │ │ ResolutionResult │
│ │ ▼ │
│ │ ┌────────────────────────┐ │
│ └─────────────────────────►│ StoreManager │ │
│ │ • verifyTarball (SRI) │ │
│ │ • atomic extract │ │
│ │ • idempotent add │ │
│ │ • gc / disk usage │ │
│ └───────────┬────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────┐ │
│ │ ~/.depvault/store/ │ │
│ │ cas/<algo>/<2>/<rest>/... │◄───┼─── canonical, read-only
│ │ packages/<name>/<version>/ │ │
│ │ meta/ tmp/ │ │
│ └────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────┐ │
│ │ project/depvault-lock.json │◄───┼─── lockfileFromResolution
│ │ • full graph (all transitive) │ │
│ │ • exact versions per importer │ │
│ │ • SRI integrity per package │ │
│ │ • CAS keys │ │
│ └────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ depvault run app.js │
│ │
│ spawns: node --require cjs-hook.cjs --import bootstrap.js app.js │
│ with env DEPVAULT_PROJECT_DIR=<project> │
│ │
│ ┌────────────────────────┐ ┌────────────────────────┐ │
│ │ CJS resolution path │ │ ESM resolution path │ │
│ │ │ │ │ │
│ │ Module._resolve… │ │ module.register() │ │
│ │ (patched) │ │ loader.resolve/load │ │
│ └───────────┬────────────┘ └───────────┬────────────┘ │
│ │ │ │
│ └────────────┬─────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────┐ │
│ │ resolver-core.cjs (shared) │ │
│ │ │ │
│ │ 1. parse depvault-lock.json │ │
│ │ 2. build CAS-path → key map │ │
│ │ 3. on each request: │ │
│ │ a. id importer │ │
│ │ b. lookup dep version │ │
│ │ c. resolve subpath │ │
│ │ (exports/main/probe) │ │
│ │ d. memoize (LRU) │ │
│ └───────────────────────────────┘ │
│ │ │
│ ▼ │
│ absolute vault path │
│ │
│ (Node loads it as if it were any other file) │
└──────────────────────────────────────────────────────────────────────────┘Install pipeline (sequence)
user CLI Installer RegistryClient DepGraph StoreManager FS (vault)
│ │ │ │ │ │ │
│ dv install │ │ │ │ │ │
├─────────────►│ │ │ │ │ │
│ │ install({...}) │ │ │ │ │
│ ├────────────────►│ │ │ │ │
│ │ │ read pkg.json │ │ │ │
│ │ ├──────────────────────────────────────────────────────────────┤
│ │ │ fast-path? if lockfile up-to-date and CAS complete → return │
│ │ │ │ │ │ │
│ │ │ resolveGraph(directDeps) │ │ │
│ │ ├────────────────►│ │ │ │
│ │ │ │ fetchPackument(name) [cached] │ │
│ │ │ ├─────────────►│ │ │
│ │ │ │ │ pickVersion │ │
│ │ │ │ │ (semver max) │ │
│ │ │ │ │ │ │
│ │ │ │ (recurse for each transitive)│ │
│ │ │ │ │ │ │
│ │ │ ResolutionResult{packages, rootDeps} │ │
│ │ │◄────────────────────────────────────────────────── │
│ │ │ │ │ │ │
│ │ │ for each missing pkg in parallel (concurrency=8): │
│ │ │ fetchTarball │ │ │ │
│ │ ├────────────────►│ │ │ │
│ │ │ addFromTarball │ │
│ │ ├────────────────────────────────────────────────►│ │
│ │ │ verify SRI ──► extract to tmp ──► atomic rename│
│ │ │ │ │ ├──────────►│
│ │ │ │ │ │ │
│ │ │ writeLockfile(depvault-lock.json) │ │
│ │ ├──────────────────────────────────────────────────────────────►
│ │ InstallReport │ │ │ │ │
│ │◄────────────────│ │ │ │ │
│ summary │ │ │ │ │ │
│◄─────────────│ │ │ │ │ │Runtime resolution (sequence)
Node process cjs-hook.cjs resolver-core.cjs FS (vault)
│ │ │ │
│ require('react') │ │ │
├────────────────────────►│ │ │
│ │ Module._resolveFilename('react', parent) │
│ │ │
│ early-out for builtins / relative paths │
│ │ │ │
│ │ core.resolve('react', parent.filename, 'require')
│ ├──────────────────────►│ │
│ │ │ identifyImporter │
│ │ │ (prefix-match │
│ │ │ parent against │
│ │ │ sortedCasPaths) │
│ │ │ │
│ │ │ resolveDepVersion │
│ │ │ (importer's deps │
│ │ │ in lockfile) │
│ │ │ │
│ │ │ resolveSubpath │
│ │ │ (exports / main / │
│ │ │ file probe) │
│ │ ├───────────────────►│
│ │ │ stat() │
│ │ │◄───────────────────┤
│ │ absolute path or null │ │
│ │◄──────────────────────│ │
│ original Module._load with the resolved path │
│◄────────────────────────│ │ │
│ │ │ │
│ (subsequent calls hit the LRU cache; ~50ns each) │Storage layout
~/.depvault/ ← override with $DEPVAULT_HOME
└── store/
├── cas/
│ └── sha512/ ← algorithm folder
│ └── ab/ ← 2-char fan-out (avoid 100k entries in one dir)
│ └── cdef0123…/ ← rest of the SRI digest (hex)
│ ├── package.json ← unpacked tarball contents
│ ├── lib/
│ └── …
├── packages/ ← human-readable index
│ ├── react/
│ │ └── 18.2.0/
│ │ └── meta.json ← { name, version, integrity, casKey, installedAt }
│ └── @scope/
│ └── pkg/
│ └── 1.0.0/meta.json
├── meta/ ← reserved (registry-level metadata cache)
└── tmp/ ← staging dirs for atomic extractionWhy content-addressed? The CAS key is derived from the tarball's SRI hash. Two byte-identical tarballs collide and dedupe automatically — this protects against the rare case where a registry republishes a version, and naturally dedupes across registry mirrors.
Lockfile shape
{
"lockfileVersion": 1,
"name": "my-app",
"version": "1.0.0",
"dependencies": { // direct deps only
"react": "18.2.0"
},
"packages": { // FULL graph, every transitive node
"[email protected]": {
"integrity": "sha512-FCSh4nwlAyt0jA2g…",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"casKey": "sha512/14/2bfa…",
"dependencies": { "loose-envify": "1.4.0" },
"type": "commonjs"
},
"[email protected]": { … }
}
}The richness of the packages map is the trick that makes runtime
resolution O(1) — every nested dep is pre-resolved at install time, so
the runtime resolver never re-runs semver math.
CLI reference
depvault install [options] # alias: i
depvault add <pkg...> [options]
depvault remove <pkg...> [options] # alias: rm
depvault run <entry|script> [-- ...args]
depvault clean [options]
depvault doctorThe shorthand dv works everywhere depvault does.
depvault install
Resolves dependencies, populates the global vault, writes depvault-lock.json.
-C, --cwd <dir> project directory (default: cwd)
-c, --concurrency <n> tarball download concurrency (default: 8)
--no-fast always re-resolve from registry, even if lockfile is currentdepvault add <pkg...>
Adds packages to package.json and runs install. Accepts:
dv add react # → "^<latest>"
dv add react@^18.2.0 # explicit range
dv add @scope/foo@latest # scoped
dv add lodash [email protected] # multipledepvault remove <pkg...>
Removes from package.json and re-installs. The vault itself is untouched —
use depvault clean to reclaim space.
depvault run <target> [-- ...args]
Spawns Node with DepVault hooks active. <target> is either a JS file path
or a key in package.json#scripts (only node … style scripts).
dv run app.js
dv run app.mjs
dv run start # runs scripts.start if it's a node script
dv run app.js -- --port 3000 # args after `--` go to your programdepvault clean
Garbage-collects the vault. A CAS entry is live iff some lockfile references it.
-C, --cwd <dir> project whose lockfile counts as live
--scan <dirs...> directories to recursively scan for depvault-lock.json (depth 4)
--dry-run show what would be removedWithout --scan, only the current project's lockfile is in the live set.
On a developer machine, run with --scan ~/code to protect every project.
depvault doctor
Diagnostic. Reports vault size, package count, lockfile/vault consistency, and packages with install scripts (likely native modules).
exit 0 → vault is consistent with lockfile
exit 1 → missing CAS entries; run `depvault install` to repairConfiguration
| env var | default | purpose |
|---|---|---|
| DEPVAULT_HOME | ~/.depvault | root of the global vault. Override for CI / shared caches. |
| DEPVAULT_REGISTRY | https://registry.npmjs.org/ | registry URL. Mirrors and private registries supported (no auth in MVP). |
| DEPVAULT_DEBUG | unset | set to 1 for verbose logging. |
| DEPVAULT_PROJECT_DIR | (auto) | set by depvault run for the spawned child; rarely set manually. |
There is no .depvaultrc file in the MVP. All config is via env vars or CLI flags.
Framework integration
DepVault works in two layers, and tools fall into one of two camps:
Layer 1 — Node's resolver. When something does require('foo') or
import 'foo' at Node runtime, our hooks intercept. This covers all
plain Node apps, server code, tsx/tsc, and the Node-side of any
build tool.
Layer 2 — bundlers' own resolvers. Vite, Next.js, webpack, esbuild,
Rollup all walk the filesystem with their own resolver code. They look
for node_modules/<pkg> directly. They don't go through Node's
require(). So they need something to find packages on disk.
DepVault handles this in two ways, summarized here and detailed below:
| status | mechanism | |---|---| | ✅ works today | Layer 1 only — pure Node apps, server code, scripts | | 🛠 use materialize fallback | Layer 2 tools — bundlers, type checkers, anything that walks disk |
The --materialize install mode (planned, see Roadmap)
generates a thin node_modules/ tree of symlinks/junctions into the
vault. It costs ~zero disk and unblocks every tool that expects a
traditional layout. Until it ships, the integrations below describe
manual workarounds.
Plain Node services
✅ Works today, no extra config.
dv install
dv run server.jsExpress, Koa, Fastify, NestJS (when run as node main.js rather than
through nest's CLI), and anything else that does runtime require/
import resolves cleanly through DepVault's hooks. The
examples/express-app demo proves this with a 72-package transitive tree.
Vite
🛠 Two paths:
a) Vite as a build tool (vite build, vite preview):
Vite's bundler uses its own filesystem-based resolver and assumes
node_modules/ exists. Until materialize ships, the workable approach
is hybrid:
# Use npm/pnpm just to populate node_modules for build-time tools
npm install --no-audit --ignore-scripts
# Use DepVault for runtime
dv install
# Build with Vite (reads node_modules)
dv run vite build
# Preview / serve with DepVault-only resolution
dv run dist/server.jsb) Vite dev server with DepVault plugin (planned):
A vite-plugin-depvault is on the roadmap. It registers a resolveId
hook that consults depvault-lock.json directly, so dev-server imports
go through the global vault with no node_modules. Rough sketch of the
plugin contract:
// vite-plugin-depvault (planned)
import depvault from 'vite-plugin-depvault';
export default { plugins: [depvault({ projectDir: process.cwd() })] };Next.js
🛠 Same shape as Vite. Next bundles via webpack/Turbopack, which need
node_modules. Until materialize ships:
# Install for both worlds
npm install --no-audit --ignore-scripts # for build-time
dv install # for runtime
# Build
dv run node_modules/next/dist/bin/next build
# Run the production server through DepVault (zero node_modules in the runtime path)
dv run .next/standalone/server.jsThe standalone output (output: 'standalone' in next.config.js)
narrows what the runtime needs, which makes it a great fit for DepVault
once materialize lands — you can ship the .next/standalone directory
plus a single depvault-lock.json and zero node_modules.
Webpack
🛠 Same constraint as Vite — webpack's enhanced-resolve walks
node_modules. The roadmap includes a ResolverPlugin for webpack 5
that forwards bare specifiers to DepVault's resolver-core. Until then,
use a real node_modules for the build, then dv run the bundle.
TypeScript
🛠 Type checking with tsc: Reads from disk. Needs node_modules
or types fed via paths in tsconfig.json.
Workarounds:
Hybrid (recommended today): install with
npm install --ignore-scriptsfor tooling, anddv installfor runtime. The two lockfiles stay in sync as long as you only manage deps viadv add/remove(which writepackage.jsonfirst).tsc --noResolve+ manual paths: for advanced setups, pointtsconfig.json#compilerOptions.pathsat the CAS dirs fromdepvault-lock.json. Works for small projects, gets unwieldy fast.
Running compiled code (node dist/index.js): ✅ works today via
dv run dist/index.js.
ts-node / tsx: ✅ works as long as you launch them through DepVault:
dv run tsx src/index.ts
# or
dv run ts-node src/index.tstsx itself uses Node's loader API; both DepVault's loader and tsx's
loader register cleanly with module.register.
esbuild / Rollup / Rspack
🛠 Same pattern as Vite/Webpack — bundler walks disk. Materialize fallback or hybrid install. DepVault-aware plugins are roadmap items.
For esbuild specifically, the resolve callback API makes the plugin
straightforward; expect this to be the first bundler integration to land.
Test runners
| runner | status | notes |
|---|---|---|
| node --test | ✅ | run with dv run and Node's built-in test runner. |
| Jest | 🛠 | uses its own resolver (jest-resolve). Plugin needed; for now use the hybrid install approach. |
| Vitest | 🛠 | inherits Vite's resolver. Same path as Vite. |
| Mocha | ✅ | dv run mocha test/**/*.test.js — Mocha uses Node's require. |
| Playwright | ✅ | runtime resolution; works directly. |
Performance
See PERFORMANCE.md for full benchmark methodology and discussion. Headline:
| metric | DepVault | npm | |---|---|---| | cold install | 2113 ms | 3860 ms | | warm install | 90 ms | 489 ms | | cold start | 187 ms | 54 ms (DepVault pays loader overhead) | | project disk | 3.3 KiB | 1.4 MiB |
The cold-start gap (~133 ms) is the cost of the CJS hook plus the ESM
loader registration. It's amortized to zero for long-running processes
(servers, dev servers) and noticeable only for very short scripts that
do many invocations (e.g. node per test file).
Security
DepVault deliberately makes a stricter security stance than npm:
- Integrity-first. Every tarball is verified against its SRI hash
(
crypto.timingSafeEqual) before extraction. Mismatches abort the install with anIntegrityError. - No code execution at install.
preinstall,install,postinstallscripts are not run. Packages declaring them are flagged bydv doctorso you can audit and rebuild manually. This trades some convenience (typed packages withpreparescripts) for closing npm's largest supply-chain vector. - Read-only vault. Runtime hooks never write into the CAS. The
vault is owned by
dv installalone. - Sandbox-friendly. The
runcommand spawns Node with the user's permissions. For hardened runs, layer Node's permission model:node --permission --allow-fs-read=$DEPVAULT_HOME --allow-fs-read=. \ --require=cjs-hook.cjs --import=bootstrap.js app.js - No transitive auth. No
.npmrctoken support in MVP. Public registries only (private registries viaDEPVAULT_REGISTRYwork if they don't require auth).
Limitations
- Lifecycle scripts not run (security stance — see above).
- Native modules: detection only.
dv doctorflags packages with install scripts. Per-platform CAS variants and adv rebuildcommand are roadmap items. Pure-JS packages work fully. - Layer-2 tools need fallback. Bundlers (Vite, Next, webpack) need
a
node_modulesuntil--materializeor per-tool plugins land. See Framework integration. - No
importsfield, no package self-reference. The resolver implements theexportsfield — including conditional + wildcard subpaths — but not the full Node spec surface. - No workspaces / monorepo wiring in MVP.
- No
.npmrcauth / private registry tokens. - Designed for Node ≥ 20.6.0 (when
module.registerbecame stable).
FAQ
Q: How is this different from pnpm?
A: pnpm has a global store, but each project still gets a node_modules
directory full of hardlinks. DepVault eliminates the project directory
entirely — Node loads modules directly out of the vault. The trade-off
is that DepVault needs Node's loader hooks at runtime (small cold-start
overhead), while pnpm projects are indistinguishable from npm projects
from Node's perspective.
Q: How is this different from Yarn PnP?
A: PnP is closer in spirit — it also bypasses node_modules. The
differences are: DepVault uses the modern module.register API instead
of patching internals; the vault layout is content-addressed (so
byte-identical tarballs dedupe across versions and registries); and
DepVault deliberately disables lifecycle scripts.
Q: What happens if I delete ~/.depvault?
A: Every project breaks until you re-run dv install somewhere. The
lockfile in each project remains valid — it can repopulate the vault.
Q: Can two projects use different versions of the same package?
A: Yes. Each project's depvault-lock.json records the versions it
uses. The CAS key is per-version, so all versions coexist in the vault.
Q: Can a single project have two versions of the same package
(typical npm "nested" case)?
A: Yes. The lockfile stores the full graph, so package A can see
lodash@3 while package B sees lodash@4 — both are in the vault, and
the resolver routes each requesting package to the right version.
Q: What about ESM-only packages?
A: Fully supported. The ESM loader inspects the package's type field
(persisted in the lockfile) to pick the right format. CJS-only and
ESM-only and dual-format packages all work.
Q: Does this work with Bun / Deno? A: No. DepVault plugs into Node's loader hooks specifically. Bun has its own bundled resolver; Deno has a different model entirely.
Q: Why is the package called depvault but the repo / module is in ndve/?
A: Historical. The project's working name was NDVE (Node Dependency
Virtualization Engine) during development. We renamed for the public
release. The repository directory may be renamed in a future commit.
Roadmap
Ordered by user impact:
dv install --materialize— generate anode_modules/directory of symlinks (or junctions on Windows) into the vault. Unblocks every Layer-2 tool (Vite, Next.js, webpack, tsc, jest) without changing their config. Disk cost remains near zero.- Native module rebuild flow —
dv rebuildrunsnode-gypin a per-platform CAS variant tagged with<platform>-<arch>-<abi>. Resolver picks the matching variant. - Bundler plugins —
vite-plugin-depvault,webpack-plugin-depvault, esbuild plugin. Lets bundlers resolve through the vault without a materializednode_modules. - Workspaces / monorepo support — root + child lockfiles, shared resolution across packages.
- Remote shared cache — pull from S3/GCS bucket;
dv installfetches CAS entries from a team mirror before falling back to the public registry. Protocol already maps onto CAS layout. dv audit— CVE checks via the npm advisories endpoint.- FUSE / projfs virtual filesystem — most ambitious; gives the
illusion of a real
node_moduleswithout the disk cost. Bypasses the need for plugin-per-tool but requires per-OS native code. - V8 startup snapshots — bake the resolver-core init into a snapshot to remove cold-start overhead.
Publishing (for maintainers)
The package is configured so a clean release is one command. From a fresh checkout:
npm run release:patch # 0.1.0 → 0.1.1, builds, publishes, pushes tags
npm run release:minor # 0.1.0 → 0.2.0
npm run release:major # 0.1.0 → 1.0.0Each release:* script does:
npm version <bump>— bumpspackage.json, makes a git commit, tags it.npm publish— runsprepare→ clean + build, then uploads.git push --follow-tags— pushes the version commit and tag.
To produce a tarball without publishing — useful for sharing internally, hosting on S3, or installing in air-gapped environments:
npm pack # → depvault-<version>.tgzSmoke test the tarball before releasing:
npm install -g ./depvault-<version>.tgz
dv --version
cd /tmp && mkdir dv-smoke && cd dv-smoke
echo '{"dependencies":{"chalk":"^4"}}' > package.json
echo 'console.log(require("chalk").green("ok"));' > app.js
dv install && dv run app.jsTo unpublish a bad release, follow npm's standard procedure — npm
unpublish is restricted to the first 72 hours and only removes
versions; users are better served by publishing a 0.1.1 with a fix.
Contributing
The codebase is laid out to make contributions easy to scope:
src/store/ ← isolated CAS layer; testable without network
src/registry/ ← all HTTP; mock the fetch for tests
src/installer/ ← orchestration only; pure logic in dep-graph.ts
src/resolver/ ← the crown jewel; resolver-core.cjs is plain JS for clarity
src/cli/ ← thin layer over the aboveTests live next to the code (*.test.ts) and run via
node --test dist/**/*.test.js after npm run build. The bench is at
scripts/bench.mjs — please include before/after numbers when changing
anything in the install or resolver hot paths.
When extending the resolver, keep resolver-core.cjs in plain
CommonJS — it's deliberately not TypeScript so both the CJS hook and
the ESM loader can require it without a compile step. The trade-off
(no static types in the hottest code path) is intentional; treat the
public functions as a stable contract and document with JSDoc.
License
MIT.
