//! `cargo xtask web-offline [DIR]`: let a web build work offline and update itself. //! //! Runs as Trunk's post_build hook (apps/solidcraft-web/Trunk.toml) on the staging directory //! (`TRUNK_STAGING_DIR`), or on DIR. It writes `sw.js` from apps/solidcraft-web/sw.js with this //! build's version and files (each with its SHA-256, which the worker checks every download //! against, so a deployment that changes mid-download never mixes into a kept build), and the //! same as `build.json` (which launches check to find a newly deployed build). The version is the //! workspace version plus a hash of all that, so any change to the build changes `sw.js`, which //! is what makes browsers install the update. //! Debug builds (`trunk serve`) get the unbuilt template instead, a worker that keeps nothing and //! retires one a release build left on the same origin. use std::path::{Path, PathBuf}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; /// The template line the build is written into. const MARKER: &str = "/* @build */"; const SW: &str = "sw.js"; const BUILD: &str = "build.json"; /// Files the app fetches only when it needs them (the CJK font, ~4.7 MB: `JAPANESE_FONT_PATH` /// in apps/solidcraft-web/src/main.rs): not kept for offline use, so not every user downloads /// them. The worker leaves them to the network. const ON_DEMAND: &[&str] = &["fonts/"]; /// The version that makes sw.js retire (the template's own). const RETIRE_VERSION: &str = "dev"; pub fn run(root: &Path, dir: Option<&str>) -> Result<(), String> { let dir = match dir { Some(d) => PathBuf::from(d), None => { let dir = PathBuf::from( std::env::var("TRUNK_STAGING_DIR").map_err(|_| "web-offline: give the built site's directory (or run as Trunk's hook)")?, ); if std::env::var("TRUNK_PROFILE").is_ok_and(|p| p != "release") { retire(root, &dir)?; println!("web-offline: debug build, {SW} retires a kept release build"); return Ok(()); } dir } }; let (version, n) = generate(root, &dir)?; println!("web-offline: {SW} keeps {n} files (version {version})"); Ok(()) } /// Write `dir/sw.js` and `dir/build.json` for the site in `dir`; returns its version and how many /// files it keeps. pub fn generate(root: &Path, dir: &Path) -> Result<(String, usize), String> { let files = list(dir)?; if !files.iter().any(|(name, _)| name == "index.html") { return Err(format!("web-offline: {} has no index.html", dir.display())); } let manifest: Vec = files.iter().map(|(name, bytes)| json!({ "file": name, "sha256": sha256(bytes) })).collect(); let manifest = Value::Array(manifest); let digest = sha256(manifest.to_string().as_bytes()); let version = format!("{}-{}", env!("CARGO_PKG_VERSION"), digest.get(..16).unwrap_or(&digest)); let template = template(root)?; let sw = render(&template, &version, &manifest)?; std::fs::write(dir.join(SW), sw).map_err(|e| format!("{}: {e}", dir.join(SW).display()))?; let build = json!({ "version": version, "files": manifest }).to_string(); std::fs::write(dir.join(BUILD), build).map_err(|e| format!("{}: {e}", dir.join(BUILD).display()))?; Ok((version, files.len())) } /// Write `dir/sw.js` as the worker that keeps nothing and retires a kept release build. pub fn retire(root: &Path, dir: &Path) -> Result<(), String> { let template = template(root)?; let sw = render(&template, RETIRE_VERSION, &json!([]))?; std::fs::write(dir.join(SW), sw).map_err(|e| format!("{}: {e}", dir.join(SW).display())) } fn template(root: &Path) -> Result { let path = root.join("apps/solidcraft-web").join(SW); std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display())) } /// Every file under `dir` (but `sw.js`, `build.json` and ON_DEMAND ones) as (URL path relative to `dir`, contents), sorted. fn list(dir: &Path) -> Result)>, String> { fn walk(dir: &Path, prefix: &str, out: &mut Vec<(String, Vec)>) -> Result<(), String> { let rd = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; for e in rd { let e = e.map_err(|e| format!("{}: {e}", dir.display()))?; let name = format!("{prefix}{}", e.file_name().to_string_lossy()); let p = e.path(); if p.is_dir() { walk(&p, &format!("{name}/"), out)?; } else if name != SW && name != BUILD && !ON_DEMAND.iter().any(|p| name.starts_with(p)) { out.push((name, std::fs::read(&p).map_err(|e| format!("{}: {e}", p.display()))?)); } } Ok(()) } let mut out = Vec::new(); walk(dir, "", &mut out)?; out.sort_by(|a, b| a.0.cmp(&b.0)); Ok(out) } /// Lower-case hex SHA-256, as the worker's `crypto.subtle.digest` check computes it. fn sha256(bytes: &[u8]) -> String { Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() } /// The template with its marker line replaced by the version and files. fn render(template: &str, version: &str, files: &Value) -> Result { let build = format!("{MARKER} const VERSION = {}, FILES = {files};", Value::from(version)); let mut found = false; let lines: Vec<&str> = template .lines() .map(|l| { if l.starts_with(MARKER) && !found { found = true; build.as_str() } else { l } }) .collect(); if !found { return Err(format!("web-offline: the {SW} template has no `{MARKER}` line")); } Ok(lines.join("\n") + "\n") } #[cfg(test)] mod tests { use super::*; #[test] fn render_replaces_the_marker_line_only() { let t = "// head\n/* @build */ const VERSION = \"dev\", FILES = [];\nconst X = 1;\n"; let files = json!([{ "file": "a \"b\".js", "sha256": "00" }]); let out = render(t, "0.3.2-00ff", &files).unwrap_or_default(); assert_eq!( out, "// head\n/* @build */ const VERSION = \"0.3.2-00ff\", FILES = [{\"file\":\"a \\\"b\\\".js\",\"sha256\":\"00\"}];\nconst X = 1;\n" ); assert!(render("no marker\n", "v", &json!([])).is_err()); } #[test] fn the_template_has_the_marker() { let t = std::fs::read_to_string(crate::root().join("apps/solidcraft-web").join(SW)).unwrap_or_default(); assert!(render(&t, "v", &json!(["x"])).is_ok_and(|s| s.contains("FILES = [\"x\"]"))); } #[test] fn sha256_is_lower_case_hex() { // FIPS 180-2's "abc" example. assert_eq!(sha256(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); } #[test] fn generate_lists_every_file_with_its_digest() { let dir = std::env::temp_dir().join(format!("solidcraft-web-offline-unit-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let ok = std::fs::create_dir_all(dir.join("snippets")) .and_then(|()| std::fs::create_dir_all(dir.join("fonts"))) .and_then(|()| std::fs::write(dir.join("fonts/big.ttf"), "on demand")) .and_then(|()| std::fs::write(dir.join("index.html"), "")) .and_then(|()| std::fs::write(dir.join("snippets/a.js"), "abc")) .and_then(|()| std::fs::write(dir.join(SW), "old")); assert!(ok.is_ok()); let (version, n) = generate(&crate::root(), &dir).unwrap_or_default(); let build: Value = std::fs::read(dir.join(BUILD)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); let _ = std::fs::remove_dir_all(&dir); assert_eq!(n, 2, "on-demand files aren't listed"); assert_eq!(build["version"], version.as_str()); assert_eq!(build["files"][1], json!({ "file": "snippets/a.js", "sha256": sha256(b"abc") })); assert_eq!(build["files"][0]["file"], "index.html"); } }