269 lines
12 KiB
Rust
269 lines
12 KiB
Rust
//! `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),
|
|
//! 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) and the precompressed files when the browser
|
|
//! accepts them.
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
use crate::{cargo, metadata, root, run as step};
|
|
|
|
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}"))?;
|
|
let mut lines = lock.lines();
|
|
while let Some(l) = lines.next() {
|
|
if l.trim() == "name = \"wasm-bindgen\""
|
|
&& let Some(v) = lines.next().and_then(|v| v.trim().strip_prefix("version = \""))
|
|
{
|
|
return Ok(v.trim_end_matches('"').to_string());
|
|
}
|
|
}
|
|
Err("wasm-bindgen not found in Cargo.lock".into())
|
|
}
|
|
|
|
fn check_bindgen(want: &str) -> Result<(), String> {
|
|
let hint = format!("install it with:\n cargo install wasm-bindgen-cli --version {want} --locked");
|
|
let out = Command::new("wasm-bindgen").arg("--version").output().map_err(|_| format!("`wasm-bindgen` CLI not found; {hint}"))?;
|
|
let have = String::from_utf8_lossy(&out.stdout);
|
|
let have = have.split_whitespace().nth(1).unwrap_or("?");
|
|
if have != want {
|
|
return Err(format!("wasm-bindgen CLI is {have} but Cargo.lock has {want}; {hint}"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn run(args: &[&str]) -> Result<(), String> {
|
|
let dev = args.contains(&"--dev");
|
|
let serve = args.iter().position(|a| *a == "--serve").map(|i| args.get(i + 1).and_then(|p| p.parse::<u16>().ok()).unwrap_or(8080));
|
|
|
|
let want = locked_bindgen_version()?;
|
|
check_bindgen(&want)?;
|
|
let meta = metadata()?;
|
|
let target_dir = PathBuf::from(meta["target_directory"].as_str().ok_or("cargo metadata: no target_directory")?);
|
|
let profile = if dev { "dev" } else { "web" };
|
|
|
|
let mut c = cargo();
|
|
c.args(["build", "-p", "lightcraft-web", "--lib", "--target", TARGET, "--profile", profile]);
|
|
step(c, &format!("cargo build -p lightcraft-web --target {TARGET} --profile {profile}"))?;
|
|
|
|
let wasm = target_dir.join(TARGET).join(if dev { "debug" } else { profile }).join("lightcraft_web.wasm");
|
|
let out = target_dir.join("web");
|
|
std::fs::create_dir_all(&out).map_err(|e| format!("{}: {e}", out.display()))?;
|
|
let mut b = Command::new("wasm-bindgen");
|
|
b.arg(&wasm).args(["--target", "web", "--no-typescript", "--out-name", "lightcraft_web", "--out-dir"]).arg(&out);
|
|
if dev {
|
|
b.arg("--debug");
|
|
}
|
|
step(b, &format!("wasm-bindgen {} → {}", wasm.display(), out.display()))?;
|
|
|
|
// optional size pass when binaryen is installed
|
|
let bg = out.join("lightcraft_web_bg.wasm");
|
|
if !dev && Command::new("wasm-opt").arg("--version").output().is_ok() {
|
|
let mut o = Command::new("wasm-opt");
|
|
o.args(["-O2", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "--enable-sign-ext", "--enable-mutable-globals"])
|
|
.arg(&bg)
|
|
.arg("-o")
|
|
.arg(&bg);
|
|
if step(o, "wasm-opt -O2").is_err() {
|
|
eprintln!("(wasm-opt failed; keeping the unoptimized module)");
|
|
}
|
|
}
|
|
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; 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",
|
|
"js" => "text/javascript; charset=utf-8",
|
|
"wasm" => "application/wasm",
|
|
"css" => "text/css; charset=utf-8",
|
|
"json" => "application/json",
|
|
"png" => "image/png",
|
|
"jpg" | "jpeg" => "image/jpeg",
|
|
"svg" => "image/svg+xml",
|
|
_ => "application/octet-stream",
|
|
}
|
|
}
|
|
|
|
/// Response headers every file gets. `Cross-Origin-Opener-Policy: same-origin` +
|
|
/// `Cross-Origin-Embedder-Policy: require-corp` make the page cross-origin isolated
|
|
/// (`crossOriginIsolated == true`): required for `SharedArrayBuffer` (a wasm-threads build) and
|
|
/// for full-resolution `performance.now()`. The current worker design (separate wasm instances)
|
|
/// works without them; they are sent so the dev server matches the recommended deployment.
|
|
pub const ISOLATION_HEADERS: [(&str, &str); 3] = [
|
|
("Cross-Origin-Opener-Policy", "same-origin"),
|
|
("Cross-Origin-Embedder-Policy", "require-corp"),
|
|
("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() {
|
|
let dir = dir.to_path_buf();
|
|
std::thread::spawn(move || {
|
|
if let Err(e) = handle(stream, &dir) {
|
|
eprintln!("http: {e}");
|
|
}
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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)?;
|
|
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();
|
|
let method = parts.next().unwrap_or("");
|
|
let target = parts.next().unwrap_or("/");
|
|
let path = target.split(['?', '#']).next().unwrap_or("/");
|
|
let rel = path.trim_start_matches('/');
|
|
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 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\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"));
|
|
}
|
|
head.push_str("Connection: close\r\n\r\n");
|
|
stream.write_all(head.as_bytes())?;
|
|
if method != "HEAD" {
|
|
stream.write_all(&body)?;
|
|
}
|
|
stream.flush()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn bindgen_version_is_locked() {
|
|
let v = locked_bindgen_version().unwrap();
|
|
assert!(v.starts_with("0.2."), "{v}");
|
|
}
|
|
|
|
#[test]
|
|
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);
|
|
}
|
|
}
|