Skip to main content

npm_utils/install/
node_modules.rs

1//! `node_modules()` — resolve a `package.json`'s transitive `dependencies` against the registry
2//! and install the flat tree.
3
4use std::path::Path;
5
6use crate::package_json::spec::{Range, Spec};
7use serde_json::Value;
8
9use crate::path_safety::safe_join;
10use crate::registry::{Registry, ResolveEvent, Resolved};
11
12/// Resolve `package_json`'s dependencies transitively, verify each tarball's registry
13/// `dist.integrity` (sha512), and extract the flat tree into `<dest>/node_modules/`. Returns the
14/// resolved set (sorted by name). A package whose registry metadata advertises no sha512 is
15/// refused rather than installed unverified. Skips all work when the resolved set is unchanged.
16pub fn node_modules(
17    package_json: &Path,
18    dest: &Path,
19) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>> {
20    node_modules_observed(package_json, dest, |_| {}, |_| {})
21}
22
23/// [`node_modules`] with progress observers: the resolve walk's [`ResolveEvent`]s, then one
24/// [`InstallEvent::Fetch`](super::InstallEvent::Fetch) immediately before each package's
25/// download/verify/extract. A skip-if-unchanged cache hit runs no populate step and emits no
26/// install events.
27pub(crate) fn node_modules_observed(
28    package_json: &Path,
29    dest: &Path,
30    on_resolve: impl Fn(ResolveEvent<'_>) + Sync,
31    mut on_install: impl FnMut(super::InstallEvent<'_>),
32) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>> {
33    let roots = root_requirements(package_json)?;
34    let resolved = Registry::npm().resolve_tree_observed(&roots, on_resolve)?;
35    let want = resolved
36        .iter()
37        .map(|r| format!("{}@{}", r.name, r.version))
38        .collect::<Vec<_>>()
39        .join("\n");
40
41    super::run_install(dest, &want, |node_modules| {
42        for (i, pkg) in resolved.iter().enumerate() {
43            let version = pkg.version.to_string();
44            on_install(super::InstallEvent::Fetch {
45                index: i + 1,
46                total: resolved.len(),
47                name: &pkg.name,
48                version: &version,
49            });
50            let dir = safe_join(node_modules, &pkg.name)?;
51            super::fetch_verify_extract(
52                &pkg.name,
53                &pkg.tarball_url,
54                pkg.integrity.as_deref(),
55                &dir,
56            )?;
57        }
58        Ok(())
59    })?;
60
61    Ok(resolved)
62}
63
64/// The root requirements: each `dependencies` entry as `(name, VersionReq)`. Specs are classified
65/// via [`Spec`]; a non-registry spec (git, remote tarball, local path, alias-to-non-registry)
66/// can't be fetched as a registry tarball and is a clear error here.
67fn root_requirements(
68    package_json: &Path,
69) -> Result<Vec<(String, Range)>, Box<dyn std::error::Error + Send + Sync>> {
70    let json: Value = serde_json::from_str(&std::fs::read_to_string(package_json)?)?;
71    let deps = json
72        .get("dependencies")
73        .and_then(Value::as_object)
74        .ok_or("no dependencies section in package.json")?;
75    let mut out = Vec::new();
76    for (name, value) in deps {
77        let Some(spec) = value.as_str() else { continue };
78        if !Spec::parse(spec).is_registry() {
79            return Err(format!(
80                "dependency `{name}`: {spec:?} is not a registry spec — git/tarball/local specs \
81                 aren't installable from the registry"
82            )
83            .into());
84        }
85        let req = Range::parse(spec)
86            .map_err(|e| format!("dependency `{name}`: unsupported version {spec:?}: {e}"))?;
87        out.push((name.clone(), req));
88    }
89    Ok(out)
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use tempfile::tempdir;
96
97    #[test]
98    fn root_requirements_classifies_via_spec() {
99        let tmp = tempdir().unwrap();
100        let pkg = tmp.path().join("package.json");
101
102        // A git spec is not a registry install → clear error.
103        std::fs::write(&pkg, r#"{"dependencies":{"x":"github:owner/repo#abc"}}"#).unwrap();
104        assert!(root_requirements(&pkg).is_err());
105
106        // A registry range resolves to a (name, VersionReq).
107        std::fs::write(&pkg, r#"{"dependencies":{"lit":"^3"}}"#).unwrap();
108        let reqs = root_requirements(&pkg).unwrap();
109        assert_eq!(reqs.len(), 1);
110        assert_eq!(reqs[0].0, "lit");
111    }
112
113    #[test]
114    #[ignore = "network: hits the npm registry"]
115    fn installs_react_with_transitive_scheduler() {
116        // Real install of the React-showcase deps. react-dom depends on scheduler, so a
117        // correct transitive resolve produces all three under node_modules/. Each tarball's
118        // registry sha512 integrity is also verified end-to-end here — a mismatch would fail
119        // the install. (Tamper-rejection itself is covered offline by
120        // `crate::integrity::tests::verify_checks_sha512_and_rejects_tampering`.)
121        let tmp = tempdir().unwrap();
122        let pkg = tmp.path().join("package.json");
123        std::fs::write(
124            &pkg,
125            r#"{ "dependencies": { "react": "^19", "react-dom": "^19" } }"#,
126        )
127        .unwrap();
128
129        let resolved = node_modules(&pkg, tmp.path()).unwrap();
130        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
131        assert!(names.contains(&"react"), "got {names:?}");
132        assert!(names.contains(&"react-dom"), "got {names:?}");
133        assert!(
134            names.contains(&"scheduler"),
135            "transitive dep missing: {names:?}"
136        );
137
138        let nm = tmp.path().join("node_modules");
139        for p in ["react", "react-dom", "scheduler"] {
140            assert!(
141                nm.join(p).join("package.json").is_file(),
142                "node_modules/{p}/package.json missing"
143            );
144        }
145    }
146
147    #[test]
148    #[ignore = "network: hits the npm registry"]
149    fn downloads_and_extracts_a_commonjs_package() {
150        use crate::package_json::{PackageJson, PackageType};
151        // `ms` is a tiny, dependency-free, long-frozen CommonJS package — a focused check that
152        // we download + extract a real CJS package *intact*. CommonJS is exactly the case a
153        // buildless ESM tree can't serve directly, which is why node_modules/ exists.
154        let tmp = tempdir().unwrap();
155        let pkg = tmp.path().join("package.json");
156        std::fs::write(&pkg, r#"{ "dependencies": { "ms": "^2" } }"#).unwrap();
157
158        let resolved = node_modules(&pkg, tmp.path()).unwrap();
159        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
160        assert_eq!(names, ["ms"], "ms has no runtime dependencies");
161
162        let ms = tmp.path().join("node_modules/ms");
163        let manifest = PackageJson::from_path(&ms.join("package.json")).unwrap();
164        assert_eq!(manifest.name(), Some("ms"));
165        assert_eq!(
166            manifest.package_type(),
167            PackageType::CommonJs,
168            "ms ships CommonJS"
169        );
170        let entry = ms.join("index.js");
171        let source = std::fs::read_to_string(&entry).unwrap();
172        assert!(
173            source.contains("module.exports"),
174            "extracted entry {entry:?} is CommonJS source"
175        );
176    }
177}