mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
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:
1 parent
8f2058a124
commit
3009f836b2
6 files changed
+163
-13
No files matched your search
Generated
+13
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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, ¶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<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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user