Skip to main content

mz_npm/
lib.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! A lightweight JavaScript package manager, like npm.
11//!
12//! There are several goals here:
13//!
14//!   * Embed the JavaScript assets into the production binary so that the
15//!     binary does not depend on any external resources, like JavaScript CDNs.
16//!     Access to these CDNs may be blocked by corporate firewalls, and old
17//!     versions of environmentd may outlive the CDNs they refer to.
18//!
19//!   * Avoid checking in the code for JavaScript assets. Checking in blobs of
20//!     JavaScript code bloats the repository and leads to merge conflicts. Plus
21//!     it is hard to verify the origin of blobs in code review.
22//!
23//!   * Avoid depending on a full Node.js/npm/webpack stack. Materialize is
24//!     primarily a Rust project, and thus most Materialize developers do not
25//!     have a Node.js installation available nor do they have the expertise to
26//!     debug the inevitable errors that arise.
27//!
28//! Our needs are simple enough that it is straightforward to download the
29//! JavaScript and CSS files we need directly from the NPM registry, without
30//! involving the actual npm tool.
31//!
32//! In our worldview, an `NpmPackage` consists of a name, version, an optional
33//! CSS file, a non-minified "development" JavaScript file, and a minified
34//! "production" JavaScript file. The CSS file, if present, is extracted into
35//! `CSS_VENDOR`. The production and development JS files are extracted into
36//! `JS_PROD_VENDOR` and `JS_DEV_VENDOR`, respectively. A SHA-256 digest of
37//! these files is computed and used to determine when the files need to be
38//! updated.
39//!
40//! To determine the file paths to use when adding a new package, visit
41//!
42//!#     <http://unpkg.com/PACKAGE@VERSION/>
43//!
44//! and browse the directory contents. (Note the trailing slash on the URL.) The
45//! compiled JavaScript/CSS assets are usually in a folder named "dist" or
46//! "UMD".
47//!
48//! To determine the correct digest, the easiest course of action is to provide
49//! a bogus digest, then build environmentd. The error message will contain the
50//! actual digest computed from the downloaded assets, which you can then copy
51//! into the `NpmPackage` struct.
52//!
53//! To reference the vendored assets, use HTML tags like the following in your
54//! templates:
55//!
56//!#     <link href="/css/vendor/package.CSS" rel="stylesheet">
57//!#     <script src="/js/vendor/PACKAGE.js"></script>
58//!
59//! The "/js/vendor/PACKAGE.js" will automatically switch between the production
60//! and development assets based on the presence of the `dev-web` feature.
61
62use 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        // Retry on any error. Transient network failures (connect errors,
215        // truncated responses) surface as request or archive errors, and a
216        // corrupt download surfaces as a digest mismatch.
217        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    // Clean up any stray files. This is more important than it might seem,
247    // since files in `CSS_VENDOR` and `JS_PROD_VENDOR` are blindly bundled into
248    // the binary at build time by the `include_dir` macro.
249    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
286/// Downloads `pkg` from the NPM registry, unpacks its files into the vendor
287/// directories, and checks the unpacked files against `pkg.digest`.
288fn 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}