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, OnceLock},
11};
12use util::http::HttpClient;
13
14const VERSION: &str = "v18.15.0";
15
16static RUNTIME_INSTANCE: OnceLock<Arc<NodeRuntime>> = OnceLock::new();
17
18#[derive(Debug, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub struct NpmInfo {
21 #[serde(default)]
22 dist_tags: NpmInfoDistTags,
23 versions: Vec<String>,
24}
25
26#[derive(Debug, Deserialize, Default)]
27pub struct NpmInfoDistTags {
28 latest: Option<String>,
29}
30
31pub struct NodeRuntime {
32 http: Arc<dyn HttpClient>,
33}
34
35impl NodeRuntime {
36 pub fn instance(http: Arc<dyn HttpClient>) -> Arc<NodeRuntime> {
37 RUNTIME_INSTANCE
38 .get_or_init(|| Arc::new(NodeRuntime { http }))
39 .clone()
40 }
41
42 pub async fn binary_path(&self) -> Result<PathBuf> {
43 let installation_path = self.install_if_needed().await?;
44 Ok(installation_path.join("bin/node"))
45 }
46
47 pub async fn run_npm_subcommand(
48 &self,
49 directory: Option<&Path>,
50 subcommand: &str,
51 args: &[&str],
52 ) -> Result<Output> {
53 let attempt = || async move {
54 let installation_path = self.install_if_needed().await?;
55
56 let mut env_path = installation_path.join("bin").into_os_string();
57 if let Some(existing_path) = std::env::var_os("PATH") {
58 if !existing_path.is_empty() {
59 env_path.push(":");
60 env_path.push(&existing_path);
61 }
62 }
63
64 let node_binary = installation_path.join("bin/node");
65 let npm_file = installation_path.join("bin/npm");
66
67 if smol::fs::metadata(&node_binary).await.is_err() {
68 return Err(anyhow!("missing node binary file"));
69 }
70
71 if smol::fs::metadata(&npm_file).await.is_err() {
72 return Err(anyhow!("missing npm file"));
73 }
74
75 let mut command = Command::new(node_binary);
76 command.env("PATH", env_path);
77 command.arg(npm_file).arg(subcommand).args(args);
78
79 if let Some(directory) = directory {
80 command.current_dir(directory);
81 }
82
83 command.output().await.map_err(|e| anyhow!("{e}"))
84 };
85
86 let mut output = attempt().await;
87 if output.is_err() {
88 output = attempt().await;
89 if output.is_err() {
90 return Err(anyhow!(
91 "failed to launch npm subcommand {subcommand} subcommand"
92 ));
93 }
94 }
95
96 if let Ok(output) = &output {
97 if !output.status.success() {
98 return Err(anyhow!(
99 "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
100 String::from_utf8_lossy(&output.stdout),
101 String::from_utf8_lossy(&output.stderr)
102 ));
103 }
104 }
105
106 output.map_err(|e| anyhow!("{e}"))
107 }
108
109 pub async fn npm_package_latest_version(&self, name: &str) -> Result<String> {
110 let output = self
111 .run_npm_subcommand(
112 None,
113 "info",
114 &[
115 name,
116 "--json",
117 "-fetch-retry-mintimeout",
118 "2000",
119 "-fetch-retry-maxtimeout",
120 "5000",
121 "-fetch-timeout",
122 "5000",
123 ],
124 )
125 .await?;
126
127 let mut info: NpmInfo = serde_json::from_slice(&output.stdout)?;
128 info.dist_tags
129 .latest
130 .or_else(|| info.versions.pop())
131 .ok_or_else(|| anyhow!("no version found for npm package {}", name))
132 }
133
134 pub async fn npm_install_packages(
135 &self,
136 directory: &Path,
137 packages: impl IntoIterator<Item = (&str, &str)>,
138 ) -> Result<()> {
139 let packages: Vec<_> = packages
140 .into_iter()
141 .map(|(name, version)| format!("{name}@{version}"))
142 .collect();
143
144 let mut arguments: Vec<_> = packages.iter().map(|p| p.as_str()).collect();
145 arguments.extend_from_slice(&[
146 "-fetch-retry-mintimeout",
147 "2000",
148 "-fetch-retry-maxtimeout",
149 "5000",
150 "-fetch-timeout",
151 "5000",
152 ]);
153
154 self.run_npm_subcommand(Some(directory), "install", &arguments)
155 .await?;
156 Ok(())
157 }
158
159 async fn install_if_needed(&self) -> Result<PathBuf> {
160 log::info!("Node runtime install_if_needed");
161
162 let arch = match consts::ARCH {
163 "x86_64" => "x64",
164 "aarch64" => "arm64",
165 other => bail!("Running on unsupported platform: {other}"),
166 };
167
168 let folder_name = format!("node-{VERSION}-darwin-{arch}");
169 let node_containing_dir = util::paths::SUPPORT_DIR.join("node");
170 let node_dir = node_containing_dir.join(folder_name);
171 let node_binary = node_dir.join("bin/node");
172 let npm_file = node_dir.join("bin/npm");
173
174 let result = Command::new(&node_binary)
175 .arg(npm_file)
176 .arg("--version")
177 .stdin(Stdio::null())
178 .stdout(Stdio::null())
179 .stderr(Stdio::null())
180 .status()
181 .await;
182 let valid = matches!(result, Ok(status) if status.success());
183
184 if !valid {
185 _ = fs::remove_dir_all(&node_containing_dir).await;
186 fs::create_dir(&node_containing_dir)
187 .await
188 .context("error creating node containing dir")?;
189
190 let file_name = format!("node-{VERSION}-darwin-{arch}.tar.gz");
191 let url = format!("https://nodejs.org/dist/{VERSION}/{file_name}");
192 let mut response = self
193 .http
194 .get(&url, Default::default(), true)
195 .await
196 .context("error downloading Node binary tarball")?;
197
198 let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
199 let archive = Archive::new(decompressed_bytes);
200 archive.unpack(&node_containing_dir).await?;
201 }
202
203 anyhow::Ok(node_dir)
204 }
205}