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