diff --git a/Cargo.lock b/Cargo.lock index b127caffd..65e80c8d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -601,6 +601,17 @@ dependencies = [ "piper", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + [[package]] name = "brotli-decompressor" version = "5.0.3" @@ -4908,6 +4919,8 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" name = "xtask" version = "0.1.0" dependencies = [ + "brotli", + "flate2", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index f52003e69..6decc70de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,8 +97,32 @@ lto = "thin" codegen-units = 1 debug = false -# `cargo xtask web`: the browser build (no debug info; size matters more than build time) +# `cargo xtask web`: the browser build. Size matters more than build time: fat LTO, one codegen +# unit, no symbols, abort on panic (wasm32 can't unwind anyway), size-optimized code — except the +# crates that do per-pixel work, which stay at opt-level 3 (opt-level "s"/"z" there costs ~50 %/ +# ~130 % render time; measured in docs/web.md → Bundle size). [profile.web] inherits = "release" debug = false -lto = "thin" +lto = "fat" +codegen-units = 1 +strip = true +panic = "abort" +opt-level = "s" + +[profile.web.package] +lightcraft-pipeline = { opt-level = 3 } +lightcraft-raster = { opt-level = 3 } +lightcraft-color = { opt-level = 3 } +lightcraft-develop = { opt-level = 3 } +lightcraft-geom = { opt-level = 3 } +lightcraft-raw = { opt-level = 3 } +lightcraft-codecs = { opt-level = 3 } +lightcraft-scenes = { opt-level = 3 } +lightcraft-preview = { opt-level = 3 } +zune-jpeg = { opt-level = 3 } +zune-core = { opt-level = 3 } +jpeg-decoder = { opt-level = 3 } +jpeg-encoder = { opt-level = 3 } +png = { opt-level = 3 } +miniz_oxide = { opt-level = 3 } diff --git a/docs/web.md b/docs/web.md index 3f8be1d99..8af632c36 100644 --- a/docs/web.md +++ b/docs/web.md @@ -28,7 +28,29 @@ it serves `.wasm` as `application/wasm`. The bundle can't be opened from `file:/ workers and OPFS need an HTTP(S) origin; `localhost`/`127.0.0.1` count as secure). If `wasm-opt` (binaryen) is on `PATH`, the release build also runs it over the module. -The release build uses the `web` Cargo profile (release + thin LTO, no debug info). +The release build uses the `web` Cargo profile (see *Bundle size* below) and writes gzip (`-9`) +and brotli (`-11`) precompressed copies next to each file (`*.gz`, `*.br`, pure Rust, printed +as a size table). `--serve` sends them with `Content-Encoding` when the browser accepts them; +configure a production server the same way (e.g. nginx `gzip_static`/`brotli_static`). + +## Bundle size + +Measured on the full bundle (every codec and raw decoder is in the module), without `wasm-opt`: + +| `web` profile | `.wasm` bytes | gzip -9 | brotli -11 | slider job* | +|-------------------------------------------------|--------------:|--------:|-----------:|------------:| +| before: release, thin LTO | 15 742 091 | 5 251 664 | 3 473 726 | 3.8 ms | +| fat LTO, 1 CGU, strip, panic=abort, opt 3 | 12 924 849 | 4 779 686 | 3 210 246 | 3.9 ms | +| same, opt-level "s" | 13 056 715 | 4 362 536 | 2 959 126 | 6.1 ms | +| same, opt-level "z" | 12 648 958 | 4 152 661 | 2 860 173 | 9.0 ms | +| **current:** "s", per-pixel crates at opt 3 | 13 533 126 | 4 583 465 | 3 093 780 | 4.0 ms | + +\* median Exposure draft render of the `?bench` loupe in a worker (stage-cached), headless +Chrome. Size-optimizing the pipeline crates costs 50–130 % render time, so `Cargo.toml` keeps +them (pipeline, raster, color, develop, geom, raw, codecs, scenes, preview and the JPEG/PNG +codecs) at opt-level 3 and the rest (egui, eframe, serde, glue) at "s". What goes over the wire +is the brotli column: 3.1 MB, 11 % less than the old build's brotli size and 41 % less than its +gzip size (5.25 MB). `wasm-opt -Oz`, when installed, shrinks it further. ## Deploying: headers diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 6d935ae8f..940bd02bc 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -10,3 +10,6 @@ workspace = true [dependencies] serde_json = { workspace = true } +# precompressed web bundle (`cargo xtask web`); pure Rust +flate2 = "1" +brotli = { version = "8", default-features = false, features = ["std"] } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6a537e081..c44460cfe 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,6 +1,6 @@ //! Workspace tooling: `cargo xtask `. //! -//! Pure Rust (std + serde_json). External tools (`cargo`, `curl`, `tar`) are +//! Pure Rust (std + serde_json; flate2/brotli for the web bundle). External tools (`cargo`, `curl`, `tar`) are //! invoked through `std::process::Command`. mod assets; diff --git a/xtask/src/web.rs b/xtask/src/web.rs index 87abf6ce6..41d086c31 100644 --- a/xtask/src/web.rs +++ b/xtask/src/web.rs @@ -1,7 +1,9 @@ //! `cargo xtask web`: build `apps/lightcraft-web` for wasm32 and bundle it with `wasm-bindgen` -//! into `/web/` (index.html + worker.js + lightcraft_web.js + lightcraft_web_bg.wasm). +//! into `/web/` (index.html + worker.js + lightcraft_web.js + lightcraft_web_bg.wasm), +//! with gzip and brotli precompressed copies (`*.gz`, `*.br`) next to each file. //! `--serve [port]` then serves that folder with a tiny static HTTP server (std only) that sends -//! the cross-origin isolation headers (COOP/COEP). +//! the cross-origin isolation headers (COOP/COEP) and the precompressed files when the browser +//! accepts them. use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; @@ -15,6 +17,9 @@ const TARGET: &str = "wasm32-unknown-unknown"; /// Files copied from `apps/lightcraft-web/` into the bundle as they are. const STATIC_FILES: [&str; 2] = ["index.html", "worker.js"]; +/// Bundle files that get precompressed copies. +const COMPRESSED: [&str; 4] = ["index.html", "worker.js", "lightcraft_web.js", "lightcraft_web_bg.wasm"]; + /// The `wasm-bindgen` version pinned in Cargo.lock (the CLI must match it exactly). fn locked_bindgen_version() -> Result { let lock = std::fs::read_to_string(root().join("Cargo.lock")).map_err(|e| format!("Cargo.lock: {e}"))?; @@ -79,18 +84,51 @@ pub fn run(args: &[&str]) -> Result<(), String> { for f in STATIC_FILES { std::fs::copy(root().join("apps/lightcraft-web").join(f), out.join(f)).map_err(|e| format!("copy {f}: {e}"))?; } + + // precompressed copies (skipped for --dev: they'd only slow the edit loop down) + println!("\n{:<26} {:>12} {:>12} {:>12}", "file", "bytes", "gzip -9", "brotli -11"); + for f in COMPRESSED { + let p = out.join(f); + let raw = std::fs::read(&p).map_err(|e| format!("{}: {e}", p.display()))?; + let (gz, br) = if dev { + for ext in ["gz", "br"] { + let _ = std::fs::remove_file(out.join(format!("{f}.{ext}"))); + } + (None, None) + } else { + let gz = gzip(&raw)?; + let br = brotli(&raw)?; + std::fs::write(out.join(format!("{f}.gz")), &gz).map_err(|e| format!("{f}.gz: {e}"))?; + std::fs::write(out.join(format!("{f}.br")), &br).map_err(|e| format!("{f}.br: {e}"))?; + (Some(gz.len()), Some(br.len())) + }; + let show = |n: Option| n.map_or("-".to_string(), |n| n.to_string()); + println!("{f:<26} {:>12} {:>12} {:>12}", raw.len(), show(gz), show(br)); + } let size = std::fs::metadata(&bg).map(|m| m.len()).unwrap_or(0); println!("\nweb build ready: {} ({:.1} MB wasm)", out.display(), size as f64 / 1e6); match serve { Some(port) => serve_dir(&out, port), None => { - println!("serve it with `cargo xtask web --serve` (or any static HTTP server) and open http://127.0.0.1:8080/"); + println!("serve it with `cargo xtask web --serve` (or any static HTTP server; see docs/web.md) and open http://127.0.0.1:8080/"); Ok(()) } } } +fn gzip(data: &[u8]) -> Result, String> { + let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best()); + e.write_all(data).and_then(|_| e.finish()).map_err(|e| format!("gzip: {e}")) +} + +fn brotli(data: &[u8]) -> Result, String> { + let mut out = Vec::new(); + let params = ::brotli::enc::BrotliEncoderParams { quality: 11, lgwin: 24, ..Default::default() }; + ::brotli::BrotliCompress(&mut &data[..], &mut out, ¶ms).map_err(|e| format!("brotli: {e}"))?; + Ok(out) +} + fn mime(path: &Path) -> &'static str { match path.extension().and_then(|e| e.to_str()).unwrap_or("") { "html" => "text/html; charset=utf-8", @@ -116,14 +154,23 @@ pub const ISOLATION_HEADERS: [(&str, &str); 3] = [ ("Cross-Origin-Resource-Policy", "same-origin"), ]; +/// Pick the precompressed variant to send: (`Content-Encoding`, file suffix). +fn negotiate(accept_encoding: &str, has: impl Fn(&str) -> bool) -> Option<(&'static str, &'static str)> { + let accepts = |enc: &str| accept_encoding.split(',').any(|e| e.split(';').next().is_some_and(|n| n.trim().eq_ignore_ascii_case(enc))); + [("br", ".br"), ("gzip", ".gz")].into_iter().find(|(enc, suffix)| accepts(enc) && has(suffix)) +} + /// Serve `dir` on 127.0.0.1:`port` (GET/HEAD only, no directory listing). Blocks. fn serve_dir(dir: &Path, port: u16) -> Result<(), String> { let listener = TcpListener::bind(("127.0.0.1", port)).map_err(|e| format!("bind 127.0.0.1:{port}: {e}"))?; println!("serving {} at http://127.0.0.1:{port}/ (Ctrl+C to stop)", dir.display()); for stream in listener.incoming().flatten() { - if let Err(e) = handle(stream, dir) { - eprintln!("http: {e}"); - } + let dir = dir.to_path_buf(); + std::thread::spawn(move || { + if let Err(e) = handle(stream, &dir) { + eprintln!("http: {e}"); + } + }); } Ok(()) } @@ -132,9 +179,14 @@ fn handle(mut stream: TcpStream, dir: &Path) -> std::io::Result<()> { let mut reader = BufReader::new(stream.try_clone()?); let mut line = String::new(); reader.read_line(&mut line)?; - // drain headers + let mut accept_encoding = String::new(); let mut h = String::new(); while reader.read_line(&mut h)? > 2 { + if let Some((k, v)) = h.split_once(':') + && k.trim().eq_ignore_ascii_case("accept-encoding") + { + accept_encoding = v.trim().to_string(); + } h.clear(); } let mut parts = line.split_whitespace(); @@ -145,12 +197,25 @@ fn handle(mut stream: TcpStream, dir: &Path) -> std::io::Result<()> { let rel = if rel.is_empty() { "index.html" } else { rel }; let file = dir.join(rel); let safe = !rel.split('/').any(|c| c == ".." || c.contains('\\')); - let (status, body, ctype) = match (method, safe, std::fs::read(&file)) { + let encoding = if safe { negotiate(&accept_encoding, |suffix| dir.join(format!("{rel}{suffix}")).is_file()) } else { None }; + let read = match encoding { + Some((_, suffix)) => std::fs::read(dir.join(format!("{rel}{suffix}"))), + None => std::fs::read(&file), + }; + let (status, body, ctype) = match (method, safe, read) { ("GET" | "HEAD", true, Ok(b)) => ("200 OK", b, mime(&file)), ("GET" | "HEAD", _, _) => ("404 Not Found", b"not found".to_vec(), "text/plain"), _ => ("405 Method Not Allowed", Vec::new(), "text/plain"), }; - let mut head = format!("HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n", body.len()); + let mut head = format!( + "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nVary: Accept-Encoding\r\n", + body.len() + ); + if status.starts_with("200") + && let Some((enc, _)) = encoding + { + head.push_str(&format!("Content-Encoding: {enc}\r\n")); + } for (k, v) in ISOLATION_HEADERS { head.push_str(&format!("{k}: {v}\r\n")); } @@ -176,5 +241,28 @@ mod tests { fn wasm_mime() { assert_eq!(mime(Path::new("a/lightcraft_web_bg.wasm")), "application/wasm"); assert_eq!(mime(Path::new("index.html")), "text/html; charset=utf-8"); + assert_eq!(mime(Path::new("worker.js")), "text/javascript; charset=utf-8"); + } + + #[test] + fn encoding_negotiation() { + let all = |_: &str| true; + assert_eq!(negotiate("gzip, deflate, br, zstd", all), Some(("br", ".br"))); + assert_eq!(negotiate("gzip;q=1.0", all), Some(("gzip", ".gz"))); + assert_eq!(negotiate("gzip, br", |s| s == ".gz"), Some(("gzip", ".gz"))); + assert_eq!(negotiate("", all), None); + assert_eq!(negotiate("identity", all), None); + } + + #[test] + fn compressors_round_trip_sizes() { + let data: Vec = (0..20_000u32).flat_map(|i| (i % 97).to_le_bytes()).collect(); + let gz = gzip(&data).unwrap(); + let br = brotli(&data).unwrap(); + assert!(gz.len() < data.len() / 4 && br.len() < data.len() / 4, "{} {} {}", data.len(), gz.len(), br.len()); + assert_eq!(&gz[..2], &[0x1f, 0x8b], "gzip magic"); + let mut back = Vec::new(); + std::io::Read::read_to_end(&mut flate2::read::GzDecoder::new(&gz[..]), &mut back).unwrap(); + assert_eq!(back, data); } }