Skip to main content

npm_utils/
install.rs

1//! Install a `package.json`'s transitive dependency tree into a `node_modules/`
2//! directory — a minimal, pure-Rust "npm install".
3//!
4//! [`node_modules`] resolves the dependency graph against the registry (see
5//! [`crate::registry::Registry::resolve_tree`]) and extracts every package into the
6//! conventional flat `node_modules/<name>/` layout (scoped names land at
7//! `node_modules/@scope/<name>/`). It is skip-if-unchanged — a marker keyed on the
8//! resolved version set — and safe under concurrent build scripts via a cross-process
9//! lock.
10//!
11//! This complements the single-package, import-map-oriented vendoring helpers: it
12//! produces a real `node_modules/` tree (CommonJS and all) for tooling (`tsc`) or a
13//! downstream bundler to consume — not browser ES modules directly.
14
15use std::path::{Path, PathBuf};
16
17use semver::VersionReq;
18use serde_json::Value;
19
20use crate::registry::{version_req, Registry, Resolved};
21use crate::{cache, download, extract};
22
23/// Resolve `package_json`'s dependencies transitively and extract the flat tree into
24/// `<dest>/node_modules/`. Returns the resolved package set (sorted by name).
25///
26/// Skips all work when the resolved version set is unchanged and `node_modules/` is
27/// already populated. Serialized across concurrent invocations by a lock kept beside
28/// `node_modules/` (a refresh wipes `node_modules/` itself, so the lock/marker can't
29/// live inside it).
30pub fn node_modules(
31    package_json: &Path,
32    dest: &Path,
33) -> Result<Vec<Resolved>, Box<dyn std::error::Error>> {
34    let roots = root_requirements(package_json)?;
35    let resolved = Registry::npm().resolve_tree(&roots)?;
36
37    let node_modules = dest.join("node_modules");
38    let lock = dest.join(".node_modules.lock");
39    let marker = dest.join(".node_modules.marker");
40    let want = resolved
41        .iter()
42        .map(|r| format!("{}@{}", r.name, r.version))
43        .collect::<Vec<_>>()
44        .join("\n");
45
46    cache::with_lock(&lock)(|| -> Result<(), Box<dyn std::error::Error>> {
47        if cache::dir_has_content(&node_modules) && cache::marker_matches(&marker, &want) {
48            return Ok(()); // already up to date
49        }
50        cache::clear_directory(&node_modules)?;
51        for pkg in &resolved {
52            let bytes = download::fetch(&pkg.tarball_url)?;
53            let dir = package_dir(&node_modules, &pkg.name)?;
54            extract::tar_gz(&bytes, &dir, Some("package/"), extract::Select::All)?;
55        }
56        cache::write_marker(&marker, &want)?;
57        Ok(())
58    })?;
59
60    Ok(resolved)
61}
62
63/// The root requirements: each `dependencies` entry as `(name, VersionReq)`, npm-faithful
64/// (a bare version pins exactly). Registry specs only — a git/URL spec errors here.
65fn root_requirements(
66    package_json: &Path,
67) -> Result<Vec<(String, VersionReq)>, Box<dyn std::error::Error>> {
68    let json: Value = serde_json::from_str(&std::fs::read_to_string(package_json)?)?;
69    let deps = json
70        .get("dependencies")
71        .and_then(Value::as_object)
72        .ok_or("no dependencies section in package.json")?;
73    let mut out = Vec::new();
74    for (name, value) in deps {
75        let Some(spec) = value.as_str() else { continue };
76        let req = version_req(spec)
77            .map_err(|e| format!("dependency `{name}`: unsupported version {spec:?}: {e}"))?;
78        out.push((name.clone(), req));
79    }
80    Ok(out)
81}
82
83/// `node_modules/<name>/`, handling scoped names (`@scope/pkg` → `@scope/pkg`) and
84/// rejecting any segment that would escape the tree.
85fn package_dir(node_modules: &Path, name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
86    let mut dir = node_modules.to_path_buf();
87    for segment in name.split('/') {
88        if segment.is_empty() || segment == "." || segment == ".." || segment.contains('\\') {
89            return Err(format!("unsafe package name {name:?}").into());
90        }
91        dir.push(segment);
92    }
93    Ok(dir)
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use flate2::write::GzEncoder;
100    use flate2::Compression;
101    use std::io::Cursor;
102    use tempfile::tempdir;
103
104    #[test]
105    fn package_dir_handles_scoped_and_rejects_escapes() {
106        let nm = Path::new("/tmp/nm");
107        assert_eq!(package_dir(nm, "react").unwrap(), nm.join("react"));
108        assert_eq!(
109            package_dir(nm, "@preact/signals").unwrap(),
110            nm.join("@preact").join("signals")
111        );
112        assert!(package_dir(nm, "../escape").is_err());
113        assert!(package_dir(nm, "a/../b").is_err());
114        assert!(package_dir(nm, "/abs").is_err());
115    }
116
117    fn tiny_tgz(files: &[(&str, &[u8])]) -> Vec<u8> {
118        let mut b = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
119        for (path, contents) in files {
120            let mut h = tar::Header::new_gnu();
121            h.set_size(contents.len() as u64);
122            h.set_mode(0o644);
123            h.set_entry_type(tar::EntryType::Regular);
124            b.append_data(&mut h, *path, Cursor::new(*contents))
125                .unwrap();
126        }
127        b.finish().unwrap();
128        b.into_inner().unwrap().finish().unwrap()
129    }
130
131    #[test]
132    fn extracts_a_package_into_the_node_modules_layout() {
133        // The per-package extraction step (offline): a scoped package lands under
134        // node_modules/@scope/pkg/ with the npm `package/` prefix stripped.
135        let tmp = tempdir().unwrap();
136        let nm = tmp.path().join("node_modules");
137        let tgz = tiny_tgz(&[
138            (
139                "package/package.json",
140                br#"{"name":"@scope/pkg","version":"1.0.0"}"#,
141            ),
142            ("package/index.js", b"export default 1;"),
143        ]);
144        let dir = package_dir(&nm, "@scope/pkg").unwrap();
145        extract::tar_gz(&tgz, &dir, Some("package/"), extract::Select::All).unwrap();
146        assert!(nm.join("@scope/pkg/package.json").is_file());
147        assert!(nm.join("@scope/pkg/index.js").is_file());
148    }
149
150    #[test]
151    #[ignore = "network: hits the npm registry"]
152    fn installs_react_with_transitive_scheduler() {
153        // Real install of the React-showcase deps. react-dom depends on scheduler, so a
154        // correct transitive resolve produces all three under node_modules/.
155        let tmp = tempdir().unwrap();
156        let pkg = tmp.path().join("package.json");
157        std::fs::write(
158            &pkg,
159            r#"{ "dependencies": { "react": "^19", "react-dom": "^19" } }"#,
160        )
161        .unwrap();
162
163        let resolved = node_modules(&pkg, tmp.path()).unwrap();
164        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
165        assert!(names.contains(&"react"), "got {names:?}");
166        assert!(names.contains(&"react-dom"), "got {names:?}");
167        assert!(
168            names.contains(&"scheduler"),
169            "transitive dep missing: {names:?}"
170        );
171
172        let nm = tmp.path().join("node_modules");
173        for p in ["react", "react-dom", "scheduler"] {
174            assert!(
175                nm.join(p).join("package.json").is_file(),
176                "node_modules/{p}/package.json missing"
177            );
178        }
179    }
180
181    #[test]
182    #[ignore = "network: hits the npm registry"]
183    fn downloads_and_extracts_a_commonjs_package() {
184        use crate::package_json::{PackageJson, PackageType};
185        // `ms` is a tiny, dependency-free, long-frozen CommonJS package — a focused check
186        // that we download + extract a real CJS package *intact*. CommonJS is exactly the
187        // case a buildless ESM tree can't serve directly, which is why node_modules/ exists.
188        let tmp = tempdir().unwrap();
189        let pkg = tmp.path().join("package.json");
190        std::fs::write(&pkg, r#"{ "dependencies": { "ms": "^2" } }"#).unwrap();
191
192        let resolved = node_modules(&pkg, tmp.path()).unwrap();
193        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
194        assert_eq!(names, ["ms"], "ms has no runtime dependencies");
195
196        let ms = tmp.path().join("node_modules/ms");
197        let manifest = PackageJson::from_path(&ms.join("package.json")).unwrap();
198        assert_eq!(manifest.name(), Some("ms"));
199        assert_eq!(
200            manifest.package_type(),
201            PackageType::CommonJs,
202            "ms ships CommonJS"
203        );
204        // The JS itself extracted to disk and really is CommonJS source. (`ms`'s "main"
205        // is the extension-less "./index"; the file on disk is index.js per its "files".)
206        let entry = ms.join("index.js");
207        let source = std::fs::read_to_string(&entry).unwrap();
208        assert!(
209            source.contains("module.exports"),
210            "extracted entry {entry:?} is CommonJS source"
211        );
212    }
213}