1use std::collections::BTreeSet;
63use std::fs;
64use std::io::Read;
65use std::path::{Path, PathBuf};
66use std::thread;
67use std::time::Duration;
68
69use anyhow::{Context, bail};
70use flate2::read::GzDecoder;
71use hex_literal::hex;
72use sha2::{Digest, Sha256};
73use walkdir::WalkDir;
74
75struct NpmPackage {
76 name: &'static str,
77 version: &'static str,
78 digest: [u8; 32],
79 css_file: Option<&'static str>,
80 js_prod_file: &'static str,
81 js_dev_file: &'static str,
82 extra_file: Option<(&'static str, &'static str)>,
83}
84
85const NPM_PACKAGES: &[NpmPackage] = &[
86 NpmPackage {
87 name: "@hpcc-js/wasm",
88 version: "0.3.14",
89 digest: hex!("b1628f561790925e58d33dcc5552aa2d1e8316a14b8436999a3c9c86df7c514a"),
90 css_file: None,
91 js_prod_file: "dist/index.min.js",
92 js_dev_file: "dist/index.js",
93 extra_file: Some((
94 "dist/graphvizlib.wasm",
95 "js/vendor/@hpcc-js/graphvizlib.wasm",
96 )),
97 },
98 NpmPackage {
99 name: "@babel/standalone",
100 version: "7.23.3",
101 digest: hex!("0f7b0deb19f1d0d77ff5dd05205be5063a930f2a6a56a6df92a6ed1e0cb73440"),
102 css_file: None,
103 js_prod_file: "babel.min.js",
104 js_dev_file: "babel.js",
105 extra_file: None,
106 },
107 NpmPackage {
108 name: "d3",
109 version: "5.16.0",
110 digest: hex!("85aa224591310c3cdd8a2ab8d3f8421bb7b0035926190389e790497c5b1d0f0b"),
111 css_file: None,
112 js_prod_file: "dist/d3.min.js",
113 js_dev_file: "dist/d3.js",
114 extra_file: None,
115 },
116 NpmPackage {
117 name: "d3-flame-graph",
118 version: "3.1.1",
119 digest: hex!("603120d8f1badfde6155816585d8e4c494f9783ae8fd40a3974928df707b1889"),
120 css_file: Some("dist/d3-flamegraph.css"),
121 js_prod_file: "dist/d3-flamegraph.min.js",
122 js_dev_file: "dist/d3-flamegraph.js",
123 extra_file: None,
124 },
125 NpmPackage {
126 name: "pako",
127 version: "1.0.11",
128 digest: hex!("1243d3fd9710c9b4e04d9528db02bfa55a4055bebc24743628fdddf59c83fa95"),
129 css_file: None,
130 js_prod_file: "dist/pako.min.js",
131 js_dev_file: "dist/pako.js",
132 extra_file: None,
133 },
134 NpmPackage {
135 name: "react",
136 version: "16.14.0",
137 digest: hex!("2fd361cfad2e0f8df36b67a0fdd43bd8064822b077fc7d70a84388918c663089"),
138 css_file: None,
139 js_prod_file: "umd/react.production.min.js",
140 js_dev_file: "umd/react.development.js",
141 extra_file: None,
142 },
143 NpmPackage {
144 name: "react-dom",
145 version: "16.14.0",
146 digest: hex!("27f6addacabaa5e5b9aa36ef443d4e79a947f3245c7d6c77f310f9c9fc944e25"),
147 css_file: None,
148 js_prod_file: "umd/react-dom.production.min.js",
149 js_dev_file: "umd/react-dom.development.js",
150 extra_file: None,
151 },
152];
153
154const STATIC: &str = "src/http/static";
155const CSS_VENDOR: &str = "src/http/static/css/vendor";
156const JS_PROD_VENDOR: &str = "src/http/static/js/vendor";
157const JS_DEV_VENDOR: &str = "src/http/static-dev/js/vendor";
158
159impl NpmPackage {
160 fn css_path(&self) -> PathBuf {
161 Path::new(CSS_VENDOR).join(format!("{}.css", self.name))
162 }
163
164 fn js_prod_path(&self) -> PathBuf {
165 Path::new(JS_PROD_VENDOR).join(format!("{}.js", self.name))
166 }
167
168 fn js_dev_path(&self) -> PathBuf {
169 Path::new(JS_DEV_VENDOR).join(format!("{}.js", self.name))
170 }
171
172 fn extra_path(&self) -> PathBuf {
173 let dst = self.extra_file.map(|(_src, dst)| dst);
174 Path::new(STATIC).join(dst.unwrap_or(""))
175 }
176
177 fn compute_digest(&self) -> Result<Vec<u8>, anyhow::Error> {
178 let css_data = if self.css_file.is_some() {
179 fs::read(self.css_path())?
180 } else {
181 vec![]
182 };
183 let js_prod_data = fs::read(self.js_prod_path())?;
184 let js_dev_data = fs::read(self.js_dev_path())?;
185 let extra_data = if self.extra_file.is_some() {
186 fs::read(self.extra_path())?
187 } else {
188 vec![]
189 };
190 Ok(Sha256::new()
191 .chain_update(Sha256::digest(css_data))
192 .chain_update(Sha256::digest(js_prod_data))
193 .chain_update(Sha256::digest(js_dev_data))
194 .chain_update(Sha256::digest(extra_data))
195 .finalize()
196 .as_slice()
197 .into())
198 }
199}
200
201pub fn ensure(out_dir: Option<PathBuf>) -> Result<(), anyhow::Error> {
202 println!("ensuring all npm packages are up-to-date...");
203 let attempts = 3;
204
205 let client = reqwest::blocking::Client::new();
206 for pkg in NPM_PACKAGES {
207 if pkg.compute_digest().ok().as_deref() == Some(&pkg.digest) {
208 println!("{} is up-to-date", pkg.name);
209 continue;
210 } else {
211 println!("{} needs updating...", pkg.name);
212 }
213
214 let mut attempt = 1;
218 loop {
219 println!(
220 "downloading {} (attempt {}/{})...",
221 pkg.name, attempt, attempts
222 );
223 match download_and_verify(&client, pkg) {
224 Ok(()) => {
225 println!("{} verified successfully.", pkg.name);
226 break;
227 }
228 Err(e) if attempt < attempts => {
229 eprintln!(
230 "attempt {}/{} for {} failed: {:#}",
231 attempt, attempts, pkg.name, e
232 );
233 thread::sleep(Duration::from_secs(if attempt == 1 { 5 } else { 15 }));
234 attempt += 1;
235 }
236 Err(e) => {
237 return Err(e.context(format!(
238 "npm package {} failed after {} attempts",
239 pkg.name, attempts
240 )));
241 }
242 }
243 }
244 }
245
246 let mut known_paths = BTreeSet::new();
250 for pkg in NPM_PACKAGES {
251 if pkg.css_file.is_some() {
252 known_paths.insert(pkg.css_path());
253 }
254 known_paths.insert(pkg.js_prod_path());
255 known_paths.insert(pkg.js_dev_path());
256 if pkg.extra_file.is_some() {
257 known_paths.insert(pkg.extra_path());
258 }
259 }
260 for dir in &[CSS_VENDOR, JS_PROD_VENDOR, JS_DEV_VENDOR] {
261 for entry in WalkDir::new(dir) {
262 let entry = entry?;
263 if entry.file_type().is_file() {
264 if !known_paths.contains(entry.path()) {
265 println!("removing stray vendor file {}", entry.path().display());
266 fs::remove_file(entry.path())?;
267 } else if let Some(out_dir) = &out_dir {
268 let dst_path = out_dir.join(entry.path());
269 println!(
270 "copying path to OUT_DIR, src {}, dst {}",
271 entry.path().display(),
272 dst_path.display(),
273 );
274 if let Some(parent) = dst_path.parent() {
275 fs::create_dir_all(parent)?;
276 }
277 fs::copy(entry.path(), dst_path)?;
278 }
279 }
280 }
281 }
282
283 Ok(())
284}
285
286fn download_and_verify(
289 client: &reqwest::blocking::Client,
290 pkg: &NpmPackage,
291) -> Result<(), anyhow::Error> {
292 let url = format!(
293 "https://registry.npmjs.org/{}/-/{}-{}.tgz",
294 pkg.name,
295 pkg.name.split('/').next_back().unwrap(),
296 pkg.version,
297 );
298
299 let res = client
300 .get(&url)
301 .send()
302 .and_then(|res| res.error_for_status())
303 .with_context(|| format!("downloading {}", pkg.name))?;
304
305 let mut archive = tar::Archive::new(GzDecoder::new(res));
306 for entry in archive.entries()? {
307 let mut entry = entry?;
308 let path = entry.path()?.strip_prefix("package")?.to_owned();
309 if let Some(css_file) = &pkg.css_file {
310 if path == Path::new(css_file) {
311 unpack_entry(&mut entry, &pkg.css_path())?;
312 }
313 }
314 if path == Path::new(pkg.js_prod_file) {
315 unpack_entry(&mut entry, &pkg.js_prod_path())?;
316 }
317 if path == Path::new(pkg.js_dev_file) {
318 unpack_entry(&mut entry, &pkg.js_dev_path())?;
319 }
320 if let Some((extra_src, _extra_dst)) = &pkg.extra_file {
321 if path == Path::new(extra_src) {
322 unpack_entry(&mut entry, &pkg.extra_path())?;
323 }
324 }
325 }
326
327 let digest = pkg
328 .compute_digest()
329 .with_context(|| format!("computing digest for {}", pkg.name))?;
330 if digest != pkg.digest {
331 bail!(
332 "digest mismatch for {}\n expected: {}\n actual: {}",
333 pkg.name,
334 hex::encode(pkg.digest),
335 hex::encode(digest)
336 );
337 }
338 Ok(())
339}
340
341fn unpack_entry<T>(entry: &mut tar::Entry<T>, target: &Path) -> Result<(), anyhow::Error>
342where
343 T: Read,
344{
345 if let Some(parent) = target.parent() {
346 fs::create_dir_all(parent)
347 .with_context(|| format!("creating directory {}", parent.display()))?;
348 }
349 entry.unpack(target)?;
350 Ok(())
351}