M0.14: web bundle size — fat LTO, size-optimized glue, precompressed output

web profile: fat LTO, 1 codegen unit, strip, panic=abort, opt-level "s", with the per-pixel
crates (pipeline, raster, color, develop, geom, raw, codecs, scenes, preview, JPEG/PNG codecs)
kept at opt-level 3: "s"/"z" there cost +50 %/+130 % render time.
wasm 15.74 MB → 13.53 MB; brotli 3.47 MB → 3.09 MB (gzip 5.25 → 4.58 MB); worker slider job
3.8 → 4.0 ms (noise).

cargo xtask web writes .gz (-9) and .br (-11) copies next to each bundle file (pure Rust:
flate2, brotli) and prints a size table; --serve negotiates Accept-Encoding and serves them
with Content-Encoding. Measurements in docs/web.md → Bundle size.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 09:02:15 -04:00
1 parent 8f2058a124
commit 3009f836b2
6 files changed
+163 -13

No files matched your search

Generated
+13
View File
@@ -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",
]
+26 -2
View File
@@ -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 }
+23 -1
View File
@@ -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
+3
View File
@@ -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"] }
+1 -1
View File
@@ -1,6 +1,6 @@
//! Workspace tooling: `cargo xtask <command>`.
//!
//! 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;
+97 -9
View File
@@ -1,7 +1,9 @@
//! `cargo xtask web`: build `apps/lightcraft-web` for wasm32 and bundle it with `wasm-bindgen`
//! into `<target>/web/` (index.html + worker.js + lightcraft_web.js + lightcraft_web_bg.wasm).
//! into `<target>/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<String, String> {
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<usize>| 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<Vec<u8>, 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<Vec<u8>, String> {
let mut out = Vec::new();
let params = ::brotli::enc::BrotliEncoderParams { quality: 11, lgwin: 24, ..Default::default() };
::brotli::BrotliCompress(&mut &data[..], &mut out, &params).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<u8> = (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);
}
}