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