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
202    let client = reqwest::blocking::Client::new();
203    for pkg in NPM_PACKAGES {
204        if pkg.compute_digest().ok().as_deref() == Some(&pkg.digest) {
205            println!("{} is up-to-date", pkg.name);
206            continue;
207        } else {
208            println!("{} needs updating...", pkg.name);
209        }
210
211        let url = format!(
212            "https://registry.npmjs.org/{}/-/{}-{}.tgz",
213            pkg.name,
214            pkg.name.split('/').next_back().unwrap(),
215            pkg.version,
216        );
217        let res = client
218            .get(url)
219            .send()
220            .and_then(|res| res.error_for_status())
221            .with_context(|| format!("downloading {}", pkg.name))?;
222        let mut archive = tar::Archive::new(GzDecoder::new(res));
223        for entry in archive.entries()? {
224            let mut entry = entry?;
225            let path = entry.path()?.strip_prefix("package")?.to_owned();
226            if let Some(css_file) = &pkg.css_file {
227                if path == Path::new(css_file) {
228                    unpack_entry(&mut entry, &pkg.css_path())?;
229                }
230            }
231            if path == Path::new(pkg.js_prod_file) {
232                unpack_entry(&mut entry, &pkg.js_prod_path())?;
233            }
234            if path == Path::new(pkg.js_dev_file) {
235                unpack_entry(&mut entry, &pkg.js_dev_path())?;
236            }
237            if let Some((extra_src, _extra_dst)) = &pkg.extra_file {
238                if path == Path::new(extra_src) {
239                    unpack_entry(&mut entry, &pkg.extra_path())?;
240                }
241            }
242        }
243
244        let digest = pkg
245            .compute_digest()
246            .with_context(|| format!("computing digest for {}", pkg.name))?;
247        if digest != pkg.digest {
248            bail!(
249                "npm package {} did not match expected digest
250expected: {}
251  actual: {}",
252                pkg.name,
253                hex::encode(pkg.digest),
254                hex::encode(digest),
255            );
256        }
257    }
258
259    // Clean up any stray files. This is more important than it might seem,
260    // since files in `CSS_VENDOR` and `JS_PROD_VENDOR` are blindly bundled into
261    // the binary at build time by the `include_dir` macro.
262    let mut known_paths = BTreeSet::new();
263    for pkg in NPM_PACKAGES {
264        if pkg.css_file.is_some() {
265            known_paths.insert(pkg.css_path());
266        }
267        known_paths.insert(pkg.js_prod_path());
268        known_paths.insert(pkg.js_dev_path());
269        if pkg.extra_file.is_some() {
270            known_paths.insert(pkg.extra_path());
271        }
272    }
273    for dir in &[CSS_VENDOR, JS_PROD_VENDOR, JS_DEV_VENDOR] {
274        for entry in WalkDir::new(dir) {
275            let entry = entry?;
276            if entry.file_type().is_file() {
277                if !known_paths.contains(entry.path()) {
278                    println!("removing stray vendor file {}", entry.path().display());
279                    fs::remove_file(entry.path())?;
280                } else if let Some(out_dir) = &out_dir {
281                    let dst_path = out_dir.join(entry.path());
282                    println!(
283                        "copying path to OUT_DIR, src {}, dst {}",
284                        entry.path().display(),
285                        dst_path.display(),
286                    );
287                    if let Some(parent) = dst_path.parent() {
288                        fs::create_dir_all(parent)?;
289                    }
290                    fs::copy(entry.path(), dst_path)?;
291                }
292            }
293        }
294    }
295
296    Ok(())
297}
298
299fn unpack_entry<T>(entry: &mut tar::Entry<T>, target: &Path) -> Result<(), anyhow::Error>
300where
301    T: Read,
302{
303    if let Some(parent) = target.parent() {
304        fs::create_dir_all(parent)
305            .with_context(|| format!("creating directory {}", parent.display()))?;
306    }
307    entry.unpack(target)?;
308    Ok(())
309}