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(Option<PrettierSupport>);
224
225struct PrettierSupport {
226 plugins: Vec<&'static str>,
227}
228
229impl FakeNodeRuntime {
230 pub fn new() -> Arc<dyn NodeRuntime> {
231 Arc::new(FakeNodeRuntime(None))
232 }
233
234 pub fn with_prettier_support(plugins: &[&'static str]) -> Arc<dyn NodeRuntime> {
235 Arc::new(FakeNodeRuntime(Some(PrettierSupport::new(plugins))))
236 }
237}
238
239#[async_trait::async_trait]
240impl NodeRuntime for FakeNodeRuntime {
241 async fn binary_path(&self) -> anyhow::Result<PathBuf> {
242 if let Some(prettier_support) = &self.0 {
243 prettier_support.binary_path().await
244 } else {
245 unreachable!()
246 }
247 }
248
249 async fn run_npm_subcommand(
250 &self,
251 directory: Option<&Path>,
252 subcommand: &str,
253 args: &[&str],
254 ) -> anyhow::Result<Output> {
255 if let Some(prettier_support) = &self.0 {
256 prettier_support
257 .run_npm_subcommand(directory, subcommand, args)
258 .await
259 } else {
260 unreachable!()
261 }
262 }
263
264 async fn npm_package_latest_version(&self, name: &str) -> anyhow::Result<String> {
265 if let Some(prettier_support) = &self.0 {
266 prettier_support.npm_package_latest_version(name).await
267 } else {
268 unreachable!()
269 }
270 }
271
272 async fn npm_install_packages(
273 &self,
274 directory: &Path,
275 packages: &[(&str, &str)],
276 ) -> anyhow::Result<()> {
277 if let Some(prettier_support) = &self.0 {
278 prettier_support
279 .npm_install_packages(directory, packages)
280 .await
281 } else {
282 unreachable!()
283 }
284 }
285}
286
287impl PrettierSupport {
288 const PACKAGE_VERSION: &str = "0.0.1";
289
290 fn new(plugins: &[&'static str]) -> Self {
291 Self {
292 plugins: plugins.to_vec(),
293 }
294 }
295}
296
297#[async_trait::async_trait]
298impl NodeRuntime for PrettierSupport {
299 async fn binary_path(&self) -> anyhow::Result<PathBuf> {
300 Ok(PathBuf::from("prettier_fake_node"))
301 }
302
303 async fn run_npm_subcommand(&self, _: Option<&Path>, _: &str, _: &[&str]) -> Result<Output> {
304 unreachable!()
305 }
306
307 async fn npm_package_latest_version(&self, name: &str) -> anyhow::Result<String> {
308 if name == "prettier" || self.plugins.contains(&name) {
309 Ok(Self::PACKAGE_VERSION.to_string())
310 } else {
311 panic!("Unexpected package name: {name}")
312 }
313 }
314
315 async fn npm_install_packages(
316 &self,
317 _: &Path,
318 packages: &[(&str, &str)],
319 ) -> anyhow::Result<()> {
320 assert_eq!(
321 packages.len(),
322 self.plugins.len() + 1,
323 "Unexpected packages length to install: {:?}, expected `prettier` + {:?}",
324 packages,
325 self.plugins
326 );
327 for (name, version) in packages {
328 assert!(
329 name == &"prettier" || self.plugins.contains(name),
330 "Unexpected package `{}` to install in packages {:?}, expected {} for `prettier` + {:?}",
331 name,
332 packages,
333 Self::PACKAGE_VERSION,
334 self.plugins
335 );
336 assert_eq!(
337 version,
338 &Self::PACKAGE_VERSION,
339 "Unexpected package version `{}` to install in packages {:?}, expected {} for `prettier` + {:?}",
340 version,
341 packages,
342 Self::PACKAGE_VERSION,
343 self.plugins
344 );
345 }
346 Ok(())
347 }
348}