1use anyhow::{anyhow, bail, Context, Result};
2use async_compression::futures::bufread::GzipDecoder;
3use async_tar::Archive;
4use serde::Deserialize;
5use smol::{fs, io::BufReader, lock::Mutex, process::Command};
6use std::process::{Output, Stdio};
7use std::{
8 env::consts,
9 path::{Path, PathBuf},
10 sync::Arc,
11};
12use util::http::HttpClient;
13
14const VERSION: &str = "v18.15.0";
15
16#[derive(Debug, Deserialize)]
17#[serde(rename_all = "kebab-case")]
18pub struct NpmInfo {
19 #[serde(default)]
20 dist_tags: NpmInfoDistTags,
21 versions: Vec<String>,
22}
23
24#[derive(Debug, Deserialize, Default)]
25pub struct NpmInfoDistTags {
26 latest: Option<String>,
27}
28
29#[async_trait::async_trait]
30pub trait NodeRuntime: Send + Sync {
31 async fn binary_path(&self) -> Result<PathBuf>;
32
33 async fn run_npm_subcommand(
34 &self,
35 directory: Option<&Path>,
36 subcommand: &str,
37 args: &[&str],
38 ) -> Result<Output>;
39
40 async fn npm_package_latest_version(&self, name: &str) -> Result<String>;
41
42 async fn npm_install_packages(&self, directory: &Path, packages: &[(&str, &str)])
43 -> Result<()>;
44}
45
46pub struct RealNodeRuntime {
47 http: Arc<dyn HttpClient>,
48 installation_lock: Mutex<()>,
49}
50
51impl RealNodeRuntime {
52 pub fn new(http: Arc<dyn HttpClient>) -> Arc<dyn NodeRuntime> {
53 Arc::new(RealNodeRuntime {
54 http,
55 installation_lock: Mutex::new(()),
56 })
57 }
58
59 async fn install_if_needed(&self) -> Result<PathBuf> {
60 let _lock = self.installation_lock.lock().await;
61 log::info!("Node runtime install_if_needed");
62
63 let os = match consts::OS {
64 "macos" => "darwin",
65 "linux" => "linux",
66 "windows" => "win",
67 other => bail!("Running on unsupported os: {other}"),
68 };
69
70 let arch = match consts::ARCH {
71 "x86_64" => "x64",
72 "aarch64" => "arm64",
73 other => bail!("Running on unsupported architecture: {other}"),
74 };
75
76 let folder_name = format!("node-{VERSION}-{os}-{arch}");
77 let node_containing_dir = util::paths::SUPPORT_DIR.join("node");
78 let node_dir = node_containing_dir.join(folder_name);
79 let node_binary = node_dir.join("bin/node");
80 let npm_file = node_dir.join("bin/npm");
81
82 let result = Command::new(&node_binary)
83 .env_clear()
84 .arg(npm_file)
85 .arg("--version")
86 .stdin(Stdio::null())
87 .stdout(Stdio::null())
88 .stderr(Stdio::null())
89 .args(["--cache".into(), node_dir.join("cache")])
90 .args(["--userconfig".into(), node_dir.join("blank_user_npmrc")])
91 .args(["--globalconfig".into(), node_dir.join("blank_global_npmrc")])
92 .status()
93 .await;
94 let valid = matches!(result, Ok(status) if status.success());
95
96 if !valid {
97 _ = fs::remove_dir_all(&node_containing_dir).await;
98 fs::create_dir(&node_containing_dir)
99 .await
100 .context("error creating node containing dir")?;
101
102 let file_name = format!("node-{VERSION}-{os}-{arch}.tar.gz");
103 let url = format!("https://nodejs.org/dist/{VERSION}/{file_name}");
104 let mut response = self
105 .http
106 .get(&url, Default::default(), true)
107 .await
108 .context("error downloading Node binary tarball")?;
109
110 let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
111 let archive = Archive::new(decompressed_bytes);
112 archive.unpack(&node_containing_dir).await?;
113 }
114
115 // Note: Not in the `if !valid {}` so we can populate these for existing installations
116 _ = fs::create_dir(node_dir.join("cache")).await;
117 _ = fs::write(node_dir.join("blank_user_npmrc"), []).await;
118 _ = fs::write(node_dir.join("blank_global_npmrc"), []).await;
119
120 anyhow::Ok(node_dir)
121 }
122}
123
124#[async_trait::async_trait]
125impl NodeRuntime for RealNodeRuntime {
126 async fn binary_path(&self) -> Result<PathBuf> {
127 let installation_path = self.install_if_needed().await?;
128 Ok(installation_path.join("bin/node"))
129 }
130
131 async fn run_npm_subcommand(
132 &self,
133 directory: Option<&Path>,
134 subcommand: &str,
135 args: &[&str],
136 ) -> Result<Output> {
137 let attempt = || async move {
138 let installation_path = self.install_if_needed().await?;
139
140 let mut env_path = installation_path.join("bin").into_os_string();
141 if let Some(existing_path) = std::env::var_os("PATH") {
142 if !existing_path.is_empty() {
143 env_path.push(":");
144 env_path.push(&existing_path);
145 }
146 }
147
148 let node_binary = installation_path.join("bin/node");
149 let npm_file = installation_path.join("bin/npm");
150
151 if smol::fs::metadata(&node_binary).await.is_err() {
152 return Err(anyhow!("missing node binary file"));
153 }
154
155 if smol::fs::metadata(&npm_file).await.is_err() {
156 return Err(anyhow!("missing npm file"));
157 }
158
159 let mut command = Command::new(node_binary);
160 command.env_clear();
161 command.env("PATH", env_path);
162 command.arg(npm_file).arg(subcommand);
163 command.args(["--cache".into(), installation_path.join("cache")]);
164 command.args([
165 "--userconfig".into(),
166 installation_path.join("blank_user_npmrc"),
167 ]);
168 command.args([
169 "--globalconfig".into(),
170 installation_path.join("blank_global_npmrc"),
171 ]);
172 command.args(args);
173
174 if let Some(directory) = directory {
175 command.current_dir(directory);
176 command.args(["--prefix".into(), directory.to_path_buf()]);
177 }
178
179 command.output().await.map_err(|e| anyhow!("{e}"))
180 };
181
182 let mut output = attempt().await;
183 if output.is_err() {
184 output = attempt().await;
185 if output.is_err() {
186 return Err(anyhow!(
187 "failed to launch npm subcommand {subcommand} subcommand"
188 ));
189 }
190 }
191
192 if let Ok(output) = &output {
193 if !output.status.success() {
194 return Err(anyhow!(
195 "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
196 String::from_utf8_lossy(&output.stdout),
197 String::from_utf8_lossy(&output.stderr)
198 ));
199 }
200 }
201
202 output.map_err(|e| anyhow!("{e}"))
203 }
204
205 async fn npm_package_latest_version(&self, name: &str) -> Result<String> {
206 let output = self
207 .run_npm_subcommand(
208 None,
209 "info",
210 &[
211 name,
212 "--json",
213 "--fetch-retry-mintimeout",
214 "2000",
215 "--fetch-retry-maxtimeout",
216 "5000",
217 "--fetch-timeout",
218 "5000",
219 ],
220 )
221 .await?;
222
223 let mut info: NpmInfo = serde_json::from_slice(&output.stdout)?;
224 info.dist_tags
225 .latest
226 .or_else(|| info.versions.pop())
227 .ok_or_else(|| anyhow!("no version found for npm package {}", name))
228 }
229
230 async fn npm_install_packages(
231 &self,
232 directory: &Path,
233 packages: &[(&str, &str)],
234 ) -> Result<()> {
235 let packages: Vec<_> = packages
236 .into_iter()
237 .map(|(name, version)| format!("{name}@{version}"))
238 .collect();
239
240 let mut arguments: Vec<_> = packages.iter().map(|p| p.as_str()).collect();
241 arguments.extend_from_slice(&[
242 "--fetch-retry-mintimeout",
243 "2000",
244 "--fetch-retry-maxtimeout",
245 "5000",
246 "--fetch-timeout",
247 "5000",
248 ]);
249
250 self.run_npm_subcommand(Some(directory), "install", &arguments)
251 .await?;
252 Ok(())
253 }
254}
255
256pub struct FakeNodeRuntime;
257
258impl FakeNodeRuntime {
259 pub fn new() -> Arc<dyn NodeRuntime> {
260 Arc::new(Self)
261 }
262}
263
264#[async_trait::async_trait]
265impl NodeRuntime for FakeNodeRuntime {
266 async fn binary_path(&self) -> anyhow::Result<PathBuf> {
267 unreachable!()
268 }
269
270 async fn run_npm_subcommand(
271 &self,
272 _: Option<&Path>,
273 subcommand: &str,
274 args: &[&str],
275 ) -> anyhow::Result<Output> {
276 unreachable!("Should not run npm subcommand '{subcommand}' with args {args:?}")
277 }
278
279 async fn npm_package_latest_version(&self, name: &str) -> anyhow::Result<String> {
280 unreachable!("Should not query npm package '{name}' for latest version")
281 }
282
283 async fn npm_install_packages(
284 &self,
285 _: &Path,
286 packages: &[(&str, &str)],
287 ) -> anyhow::Result<()> {
288 unreachable!("Should not install packages {packages:?}")
289 }
290}