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
7pub struct WebBundlerOpt {
9 pub src_dir: PathBuf,
11 pub dist_dir: PathBuf,
13 pub tmp_dir: PathBuf,
15 pub base_url: Option<String>,
17 pub wasm_version: String,
19 pub release: bool,
21 pub workspace_root: PathBuf,
23 pub additional_watch_dirs: Vec<PathBuf>,
25}
26
27pub 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
92fn 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 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}