web_bundler/
lib.rs

1use anyhow::{anyhow, Context, Result};
2use rand::{thread_rng, Rng};
3use std::{collections::HashMap, fs, path::PathBuf, thread, time::Duration};
4use tera::Tera;
5use wasm_pack::command::build::{Build, BuildOptions};
6
7/// Options passed to [`run()`] for bundling a web application.
8pub struct WebBundlerOpt {
9    /// Where to look for input files. Usually the root of the SPA crate.
10    pub src_dir: PathBuf,
11    /// The directory where output should be written to. In build.rs scripts, this should be read from the "OUT_DIR" environment variable.
12    pub dist_dir: PathBuf,
13    /// A directory that web-bundler can use to store temporary artifacts.
14    pub tmp_dir: PathBuf,
15    /// Passed into the index.html template as base_url. Example template usage: `<base href="{{ base_url }}">`
16    pub base_url: Option<String>,
17    /// Rename the webassembly bundle to include this version number.
18    pub wasm_version: String,
19    /// Build in release mode, instad of debug mode.
20    pub release: bool,
21    /// Path to the root of the workspace. A new target directory, called 'web-target' is placed there. If you aren't using a workspace, this can be wherever your `target` directory lives.
22    pub workspace_root: PathBuf,
23    /// Any additional directories that, if changes happen here, a rebuild is required.
24    pub additional_watch_dirs: Vec<PathBuf>,
25}
26
27/// Bundles a web application for publishing
28///
29/// - This will run wasm-pack for the indicated crate.
30/// - An index.html file will be read from the src_dir, and processed with the Tera templating engine.
31/// - The .wasm file is versioned.
32/// - Files in ./static are copied to the output without modification.
33/// - If the file ./css/style.scss exists, it is compiled to CSS which can be inlined into the HTML template.
34///
35/// # Command Line Output
36///
37/// This function is intended to be called from a Cargo build.rs
38/// script. It writes [Cargo
39/// rerun-if-changed](https://doc.rust-lang.org/cargo/reference/build-scripts.html#outputs-of-the-build-script)
40/// directives to stdout.
41///
42/// # Example index.html
43///
44/// ```html
45/// <!DOCTYPE html>
46/// <html lang="en">
47///     <head>
48///         <base href="{{ base_url }}">
49///         <meta charset="utf-8">
50///         <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
51///
52///         {{ stylesheet | safe }}
53///
54///         <title>My Amazing Website</title>
55///     </head>
56///     <body>
57///         <div id="app"></div>
58///         {{ javascript | safe }}
59///     </body>
60/// </html>
61/// ```
62///
63/// # Thread Safety
64///
65/// This function sets and unsets environment variables, and so is not
66/// safe to use in multithreaded build scripts.
67///
68/// It is safe to run multiple web-bundlers at the same time if they
69/// are in different build.rs scripts, since Cargo runs each build.rs
70/// script in its own process.
71pub fn run(opt: WebBundlerOpt) -> Result<()> {
72    list_cargo_rerun_if_changed_files(&opt)?;
73
74    run_wasm_pack(&opt, 3)?;
75    prepare_dist_directory(&opt)?;
76    bundle_assets(&opt)?;
77    bundle_js_snippets(&opt)?;
78    bundle_index_html(&opt)?;
79    bundle_app_wasm(&opt)?;
80    Ok(())
81}
82
83fn list_cargo_rerun_if_changed_files(opt: &WebBundlerOpt) -> Result<()> {
84    println!("cargo:rerun-if-changed={}", &opt.src_dir.display());
85
86    for additional_watch_dir in &opt.additional_watch_dirs {
87        println!("cargo:rerun-if-changed={}", additional_watch_dir.display());
88    }
89    Ok(())
90}
91
92/// Clears any environment variables that Cargo has set for this build
93/// script, so that they don't accidentally leak into build scripts
94/// that run as part of the Wasm
95/// build.
96///
97/// The list of variables to clear comes from here:
98/// https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-build-scripts
99///
100/// These variables are all reset to their original value after running.
101///
102/// Additional variables can be set if the caller wants to temporarily
103/// change their value.
104fn run_with_clean_build_script_environment_variables<T>(
105    additional_vars: impl IntoIterator<Item = &'static str>,
106    f: impl Fn() -> T,
107) -> T {
108    use std::ffi::OsString;
109
110    let mut existing_values: HashMap<OsString, Option<OsString>> = HashMap::new();
111    let build_script_vars_list = vec![
112        "CARGO",
113        "CARGO_MANIFEST_DIR",
114        "CARGO_MANIFEST_LINKS",
115        "CARGO_MAKEFLAGS",
116        "OUT_DIR",
117        "TARGET",
118        "HOST",
119        "NUM_JOBS",
120        "OPT_LEVEL",
121        "DEBUG",
122        "PROFILE",
123        "RUSTC",
124        "RUSTDOC",
125        "RUSTC_LINKER",
126    ];
127
128    let build_script_var_prefixes = vec!["CARGO_FEATURE_", "CARGO_CFG_", "DEP_"];
129
130    for key in build_script_vars_list
131        .into_iter()
132        .chain(additional_vars.into_iter())
133    {
134        existing_values.insert(key.into(), std::env::var_os(key));
135        std::env::remove_var(key);
136    }
137
138    for (key, value) in std::env::vars_os() {
139        if build_script_var_prefixes
140            .iter()
141            .any(|prefix| key.to_string_lossy().starts_with(prefix))
142        {
143            existing_values.insert(key.clone(), Some(value));
144            std::env::remove_var(key);
145        }
146    }
147
148    let result = f();
149
150    for (key, value) in existing_values {
151        match value {
152            Some(value) => std::env::set_var(key, value),
153            None => std::env::remove_var(key),
154        }
155    }
156    result
157}
158
159fn run_wasm_pack(opt: &WebBundlerOpt, retries: u32) -> Result<()> {
160    run_with_clean_build_script_environment_variables(vec!["CARGO_TARGET_DIR"], || {
161        let target_dir = opt.workspace_root.join("web-target");
162
163        std::env::set_var("CARGO_TARGET_DIR", target_dir.as_os_str());
164
165        let build_opts = BuildOptions {
166            path: Some(opt.src_dir.clone()),
167            scope: None,
168            mode: wasm_pack::install::InstallMode::Normal,
169            disable_dts: true,
170            target: wasm_pack::command::build::Target::Web,
171            debug: !opt.release,
172            dev: !opt.release,
173            release: opt.release,
174            profiling: false,
175            out_dir: opt
176                .tmp_dir
177                .clone()
178                .into_os_string()
179                .into_string()
180                .map_err(|_| anyhow!("couldn't parse tmp_dir into a String"))?,
181            out_name: Some("package".to_owned()),
182            extra_options: vec![],
183            reference_types: false,
184            weak_refs: false,
185            no_pack: true,
186        };
187
188        let res = Build::try_from_opts(build_opts).and_then(|mut b| b.run());
189
190        match res {
191            Ok(_) => Ok(()),
192            Err(e) => {
193                let is_wasm_cache_error = e.to_string().contains("Error: Directory not empty")
194                    || e.to_string().contains("binary does not exist");
195
196                if is_wasm_cache_error && retries > 0 {
197                    // This step could error because of a legitimate failure,
198                    // or it could error because two parallel wasm-pack
199                    // processes are conflicting over WASM_PACK_CACHE. This
200                    // random wait in an attempt to get them restarting at
201                    // different times.
202                    let wait_ms = thread_rng().gen_range(1000..5000);
203                    thread::sleep(Duration::from_millis(wait_ms));
204                    run_wasm_pack(opt, retries - 1)
205                } else {
206                    Err(anyhow!(e))
207                }
208            }
209        }
210    })
211}
212
213fn prepare_dist_directory(opt: &WebBundlerOpt) -> Result<()> {
214    if opt.dist_dir.is_dir() {
215        fs::remove_dir_all(&opt.dist_dir).with_context(|| {
216            format!(
217                "Failed to clear old dist directory ({})",
218                opt.dist_dir.display()
219            )
220        })?;
221    }
222    fs::create_dir_all(&opt.dist_dir).with_context(|| {
223        format!(
224            "Failed to create the dist directory ({})",
225            opt.dist_dir.display()
226        )
227    })?;
228    Ok(())
229}
230
231fn bundle_assets(opt: &WebBundlerOpt) -> Result<()> {
232    let src = opt.src_dir.join("static");
233    let dest = &opt.dist_dir;
234    if src.exists() {
235        fs_extra::dir::copy(&src, &dest, &fs_extra::dir::CopyOptions::new()).with_context(
236            || {
237                format!(
238                    "Failed to copy static files from {} to {}",
239                    src.display(),
240                    dest.display()
241                )
242            },
243        )?;
244    }
245    Ok(())
246}
247
248fn bundle_index_html(opt: &WebBundlerOpt) -> Result<()> {
249    let src_index_path = opt.src_dir.join("index.html");
250    let index_html_template = fs::read_to_string(&src_index_path).with_context(|| {
251        format!(
252            "Failed to read {}. This should be a source code file checked into the repo.",
253            src_index_path.display()
254        )
255    })?;
256
257    let mut tera_context = tera::Context::new();
258
259    let js_path = std::path::Path::new(opt.base_url.as_ref().unwrap_or(&"".to_string()))
260        .join(format!("app-{}.js", opt.wasm_version));
261    let wasm_path = std::path::Path::new(opt.base_url.as_ref().unwrap_or(&"".to_string()))
262        .join(format!("app-{}.wasm", opt.wasm_version));
263
264    let javascript = format!(
265        r#"<script type="module">import init from '{}'; init('{}'); </script>"#,
266        js_path.display(),
267        wasm_path.display()
268    );
269    tera_context.insert("javascript", &javascript);
270
271    tera_context.insert("base_url", opt.base_url.as_deref().unwrap_or("/"));
272
273    let sass_options = sass_rs::Options {
274        output_style: sass_rs::OutputStyle::Compressed,
275        precision: 4,
276        indented_syntax: true,
277        include_paths: Vec::new(),
278    };
279    let style_src_path = opt.src_dir.join("css/style.scss");
280    let style_css_content = sass_rs::compile_file(&style_src_path, sass_options)
281        .map_err(|e| anyhow!("Sass compilation failed: {}", e))?;
282
283    let stylesheet = format!("<style>{}</style>", style_css_content);
284    tera_context.insert("stylesheet", &stylesheet);
285
286    let index_html_content = Tera::one_off(&index_html_template, &tera_context, true)?;
287
288    let dest_index_path = opt.dist_dir.join("index.html");
289    fs::write(&dest_index_path, index_html_content).with_context(|| {
290        format!(
291            "Failed to write the index.html file to {}",
292            dest_index_path.display()
293        )
294    })?;
295
296    Ok(())
297}
298
299fn bundle_app_wasm(opt: &WebBundlerOpt) -> Result<()> {
300    let src_wasm = opt.tmp_dir.join("package_bg.wasm");
301    let dest_wasm = opt.dist_dir.join(format!("app-{}.wasm", opt.wasm_version));
302    fs::copy(&src_wasm, &dest_wasm).with_context(|| {
303        format!(
304            "Failed to copy application wasm from {} to {}",
305            src_wasm.display(),
306            dest_wasm.display()
307        )
308    })?;
309
310    let src_js = opt.tmp_dir.join("package.js");
311    let dest_js = opt.dist_dir.join(format!("app-{}.js", opt.wasm_version));
312    fs::copy(&src_js, &dest_js).with_context(|| {
313        format!(
314            "Failed to copy application javascript from {} to {}",
315            src_js.display(),
316            dest_js.display()
317        )
318    })?;
319    Ok(())
320}
321
322fn bundle_js_snippets(opt: &WebBundlerOpt) -> Result<()> {
323    let src = opt.tmp_dir.join("snippets");
324    let dest = &opt.dist_dir;
325
326    if src.exists() {
327        fs_extra::dir::copy(&src, &dest, &fs_extra::dir::CopyOptions::new()).with_context(
328            || {
329                format!(
330                    "Failed to copy js snippets from {} to {}",
331                    src.display(),
332                    dest.display()
333                )
334            },
335        )?;
336    }
337    Ok(())
338}