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 arch = match consts::ARCH {
64 "x86_64" => "x64",
65 "aarch64" => "arm64",
66 other => bail!("Running on unsupported platform: {other}"),
67 };
68
69 let folder_name = format!("node-{VERSION}-darwin-{arch}");
70 let node_containing_dir = util::paths::SUPPORT_DIR.join("node");
71 let node_dir = node_containing_dir.join(folder_name);
72 let node_binary = node_dir.join("bin/node");
73 let npm_file = node_dir.join("bin/npm");
74
75 let result = Command::new(&node_binary)
76 .arg(npm_file)
77 .arg("--version")
78 .stdin(Stdio::null())
79 .stdout(Stdio::null())
80 .stderr(Stdio::null())
81 .args(["--cache".into(), node_dir.join("cache")])
82 .args(["--userconfig".into(), node_dir.join("blank_user_npmrc")])
83 .args(["--globalconfig".into(), node_dir.join("blank_global_npmrc")])
84 .status()
85 .await;
86 let valid = matches!(result, Ok(status) if status.success());
87
88 if !valid {
89 _ = fs::remove_dir_all(&node_containing_dir).await;
90 fs::create_dir(&node_containing_dir)
91 .await
92 .context("error creating node containing dir")?;
93
94 let file_name = format!("node-{VERSION}-darwin-{arch}.tar.gz");
95 let url = format!("https://nodejs.org/dist/{VERSION}/{file_name}");
96 let mut response = self
97 .http
98 .get(&url, Default::default(), true)
99 .await
100 .context("error downloading Node binary tarball")?;
101
102 let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
103 let archive = Archive::new(decompressed_bytes);
104 archive.unpack(&node_containing_dir).await?;
105 }
106
107 // Note: Not in the `if !valid {}` so we can populate these for existing installations
108 _ = fs::create_dir(node_dir.join("cache")).await;
109 _ = fs::write(node_dir.join("blank_user_npmrc"), []).await;
110 _ = fs::write(node_dir.join("blank_global_npmrc"), []).await;
111
112 anyhow::Ok(node_dir)
113 }
114}
115
116#[async_trait::async_trait]
117impl NodeRuntime for RealNodeRuntime {
118 async fn binary_path(&self) -> Result<PathBuf> {
119 let installation_path = self.install_if_needed().await?;
120 Ok(installation_path.join("bin/node"))
121 }
122
123 async fn run_npm_subcommand(
124 &self,
125 directory: Option<&Path>,
126 subcommand: &str,
127 args: &[&str],
128 ) -> Result<Output> {
129 let attempt = || async move {
130 let installation_path = self.install_if_needed().await?;
131
132 let mut env_path = installation_path.join("bin").into_os_string();
133 if let Some(existing_path) = std::env::var_os("PATH") {
134 if !existing_path.is_empty() {
135 env_path.push(":");
136 env_path.push(&existing_path);
137 }
138 }
139
140 let node_binary = installation_path.join("bin/node");
141 let npm_file = installation_path.join("bin/npm");
142
143 if smol::fs::metadata(&node_binary).await.is_err() {
144 return Err(anyhow!("missing node binary file"));
145 }
146
147 if smol::fs::metadata(&npm_file).await.is_err() {
148 return Err(anyhow!("missing npm file"));
149 }
150
151 let mut command = Command::new(node_binary);
152 command.env("PATH", env_path);
153 command.arg(npm_file).arg(subcommand);
154 command.args(["--cache".into(), installation_path.join("cache")]);
155 command.args([
156 "--userconfig".into(),
157 installation_path.join("blank_user_npmrc"),
158 ]);
159 command.args([
160 "--globalconfig".into(),
161 installation_path.join("blank_global_npmrc"),
162 ]);
163 command.args(args);
164
165 if let Some(directory) = directory {
166 command.current_dir(directory);
167 }
168
169 command.output().await.map_err(|e| anyhow!("{e}"))
170 };
171
172 let mut output = attempt().await;
173 if output.is_err() {
174 output = attempt().await;
175 if output.is_err() {
176 return Err(anyhow!(
177 "failed to launch npm subcommand {subcommand} subcommand"
178 ));
179 }
180 }
181
182 if let Ok(output) = &output {
183 if !output.status.success() {
184 return Err(anyhow!(
185 "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
186 String::from_utf8_lossy(&output.stdout),
187 String::from_utf8_lossy(&output.stderr)
188 ));
189 }
190 }
191
192 output.map_err(|e| anyhow!("{e}"))
193 }
194
195 async fn npm_package_latest_version(&self, name: &str) -> Result<String> {
196 let output = self
197 .run_npm_subcommand(
198 None,
199 "info",
200 &[
201 name,
202 "--json",
203 "-fetch-retry-mintimeout",
204 "2000",
205 "-fetch-retry-maxtimeout",
206 "5000",
207 "-fetch-timeout",
208 "5000",
209 ],
210 )
211 .await?;
212
213 let mut info: NpmInfo = serde_json::from_slice(&output.stdout)?;
214 info.dist_tags
215 .latest
216 .or_else(|| info.versions.pop())
217 .ok_or_else(|| anyhow!("no version found for npm package {}", name))
218 }
219
220 async fn npm_install_packages(
221 &self,
222 directory: &Path,
223 packages: &[(&str, &str)],
224 ) -> Result<()> {
225 let packages: Vec<_> = packages
226 .into_iter()
227 .map(|(name, version)| format!("{name}@{version}"))
228 .collect();
229
230 let mut arguments: Vec<_> = packages.iter().map(|p| p.as_str()).collect();
231 arguments.extend_from_slice(&[
232 "-fetch-retry-mintimeout",
233 "2000",
234 "-fetch-retry-maxtimeout",
235 "5000",
236 "-fetch-timeout",
237 "5000",
238 ]);
239
240 self.run_npm_subcommand(Some(directory), "install", &arguments)
241 .await?;
242 Ok(())
243 }
244}
245
246pub struct FakeNodeRuntime;
247
248impl FakeNodeRuntime {
249 pub fn new() -> Arc<dyn NodeRuntime> {
250 Arc::new(Self)
251 }
252}
253
254#[async_trait::async_trait]
255impl NodeRuntime for FakeNodeRuntime {
256 async fn binary_path(&self) -> anyhow::Result<PathBuf> {
257 unreachable!()
258 }
259
260 async fn run_npm_subcommand(
261 &self,
262 _: Option<&Path>,
263 subcommand: &str,
264 args: &[&str],
265 ) -> anyhow::Result<Output> {
266 unreachable!("Should not run npm subcommand '{subcommand}' with args {args:?}")
267 }
268
269 async fn npm_package_latest_version(&self, name: &str) -> anyhow::Result<String> {
270 unreachable!("Should not query npm package '{name}' for latest version")
271 }
272
273 async fn npm_install_packages(
274 &self,
275 _: &Path,
276 packages: &[(&str, &str)],
277 ) -> anyhow::Result<()> {
278 unreachable!("Should not install packages {packages:?}")
279 }
280}