npm_utils/install/
node_modules.rs1use 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
12pub 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
23pub(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
64fn 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 std::fs::write(&pkg, r#"{"dependencies":{"x":"github:owner/repo#abc"}}"#).unwrap();
104 assert!(root_requirements(&pkg).is_err());
105
106 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 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 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}