Modules and packages
How Cruft installs and loads code from one binary. cruft install resolves the dependency graph, writes cruft-lock.json, and fills node_modules from a content-addressed store; the module loader then consumes that same graph to resolve ES modules, CommonJS, node:* built-ins, and erased TypeScript imports.
Getting code loaded in Node takes two programs. First npm (or pnpm, or yarn) resolves your package.json and fills node_modules. Then, entirely separately, Node's resolver reads node_modules back and answers your imports. They coordinate through a directory layout and a set of conventions, which is the source of a familiar class of failures: a phantom dependency you can import but never declared, an install that succeeds while the runtime resolves something else, a package that behaves one way in its postinstall and another way at import time with nothing tying the two together.
Cruft runs both from one binary, and the join between "what was installed" and "what gets loaded" is a designed interface. This page walks the whole path, cruft install to node_modules to import to running code, and points out where the single seam changes what you can rely on.
Installing
cruft install reads your package.json dependencies and resolves them against the npm registry the way npm does, the same versions, the same peer-dependency behavior, the same registry, then materializes node_modules. Two properties are worth holding onto:
- A content-addressed store, pnpm-style. Packages download once into a global store (
~/.cruft/storeby default), keyed by integrity hash, and hard-link into each project. A version installed anywhere is on disk exactly once. SetCRUFT_INSTALL_MODE=isolatedto copy instead, for fully self-contained trees. - A lockfile.
cruft-lock.jsonpins the resolved graph, so the next install is exact and reproducible and a secondcruft installre-resolves nothing, the same guarantee aspackage-lock.jsonorpnpm-lock.yaml.
Loading: ESM and CommonJS
Cruft decides whether each file is an ES module or CommonJS exactly as Node does, by extension (.mjs/.cjs) and the nearest package.json "type" field, and runs it down the matching path:
- ESM files get a module record in a graph keyed by resolved URL. Dependencies evaluate before dependents, exports are live bindings backed by shared cells (read a namespace later and you observe a mutation), and top-level
awaitis scheduled correctly across the graph. - CommonJS files evaluate through the classic wrapper with
exports,module, andrequire, and the finalmodule.exportslands on the record.
They interoperate. When ESM imports CJS, Cruft projects the export value, and it statically detects the recognizable exports.name = … writes so that Node-style named imports from a CommonJS package work, without the usual .default dance.
Built-in modules
import fs from "node:fs" (or bare fs) never touches the filesystem to find fs. A built-in resolver, a host surface deliberately outside the engine, maps node:* and bare built-in specifiers onto module objects the host installed at startup. The engine's module graph treats them like any other module; the host decides what they contain. This is the exact seam where Node compatibility is built.
TypeScript sources
.ts, .mts, and .cts are first-class module sources. The loader strips their types at the door (erasure, the same strip-types model Node uses: no type checking, no transform beyond removing annotations) and feeds the resulting JavaScript into the same classification and evaluation machinery. From the module graph's point of view TypeScript does not exist, it was erased before the graph ever saw it. No build step, no dist/, no second copy of your tree.
What the one binary buys
Because the installer and the loader are one codebase rather than two programs agreeing by convention:
- The loader trusts the store's integrity hashes and the lockfile's graph directly, instead of re-deriving trust from whatever is currently on disk in
node_modules. - A package's declared capabilities (
capsinpackage.json, used by sealed mode) are enforced at load time by the same machinery that installed the package, closing the install-time-versus-import-time gap Node leaves open. --sealed-depscan draw its "is this a dependency?" line from the real package graph, not from anode_modulespath heuristic that a symlink or a hoist can fool.