Skip to main content

xtask/
web.rs

1use std::io::BufRead;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4
5pub struct Options {
6    pub bin: String,
7    pub package: Option<String>,
8    pub release: bool,
9    pub public_url: String,
10    pub features: Vec<String>,
11    pub manifest_path: Option<PathBuf>,
12    pub out_dir: Option<PathBuf>,
13}
14
15impl Options {
16    fn package(&self) -> &str {
17        self.package.as_deref().unwrap_or(&self.bin)
18    }
19}
20
21pub fn parse(args: &[String]) -> Result<Options, String> {
22    let mut opts = Options {
23        bin: "polytope_playground".to_string(),
24        package: None,
25        release: false,
26        public_url: "/".to_string(),
27        features: Vec::new(),
28        manifest_path: None,
29        out_dir: None,
30    };
31    let mut args = args.iter();
32    while let Some(arg) = args.next() {
33        match arg.as_str() {
34            "--release" => opts.release = true,
35            "--bin" => opts.bin = args.next().cloned().ok_or("--bin needs a name")?,
36            "--package" => {
37                opts.package = Some(args.next().cloned().ok_or("--package needs a name")?);
38            }
39            "--public-url" => {
40                opts.public_url = args.next().cloned().ok_or("--public-url needs a path")?;
41            }
42            "--features" => {
43                let list = args.next().cloned().ok_or("--features needs a list")?;
44                opts.features.extend(
45                    list.split(',')
46                        .filter(|name| !name.is_empty())
47                        .map(String::from),
48                );
49            }
50            "--manifest-path" => {
51                let path = args.next().ok_or("--manifest-path needs a Cargo.toml")?;
52                opts.manifest_path = Some(PathBuf::from(path));
53            }
54            "--out-dir" => {
55                let dir = args.next().ok_or("--out-dir needs a directory")?;
56                opts.out_dir = Some(PathBuf::from(dir));
57            }
58            other => return Err(format!("unknown flag: {other}")),
59        }
60    }
61    if !opts.public_url.ends_with('/') {
62        opts.public_url.push('/');
63    }
64    Ok(opts)
65}
66
67pub fn run(opts: &Options) -> Result<(), String> {
68    let root = crate::workspace_root()?;
69    let manifest = opts
70        .manifest_path
71        .as_deref()
72        .map(std::path::absolute)
73        .transpose()
74        .map_err(|e| format!("--manifest-path: {e}"))?;
75    let manifest_dir = match &manifest {
76        Some(path) => path
77            .parent()
78            .ok_or_else(|| format!("{} has no directory", path.display()))?
79            .to_path_buf(),
80        None => root.clone(),
81    };
82
83    let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
84    let mut command = Command::new(cargo);
85    command
86        .current_dir(&manifest_dir)
87        .args(["build", "--target", "wasm32-unknown-unknown"])
88        .args(["-p", opts.package(), "--bin", &opts.bin])
89        .arg("--no-default-features")
90        .arg("--message-format=json-render-diagnostics")
91        .stdout(Stdio::piped());
92    if let Some(manifest) = &manifest {
93        command.arg("--manifest-path").arg(manifest);
94    }
95    if opts.release {
96        command.args(["--profile", "wasm-release"]);
97        remap_paths(&mut command, &root)?;
98    }
99    for feature in &opts.features {
100        if let Some((package, _)) = feature.split_once('/') {
101            command.args(["-p", package]);
102        }
103        command.args(["--features", feature]);
104    }
105    let mut child = command.spawn().map_err(|e| format!("run cargo: {e}"))?;
106    let stdout = child.stdout.take().ok_or("cargo stdout is not piped")?;
107    let wasm = wasm_artifact(std::io::BufReader::new(stdout), &opts.bin);
108    let status = child.wait().map_err(|e| format!("wait for cargo: {e}"))?;
109    if !status.success() {
110        return Err(format!("cargo build failed: {status}"));
111    }
112    let wasm = wasm?;
113
114    let out_dir = opts
115        .out_dir
116        .clone()
117        .unwrap_or_else(|| root.join("dist").join(&opts.bin));
118    std::fs::create_dir_all(&out_dir).map_err(|e| format!("create {}: {e}", out_dir.display()))?;
119
120    wasm_bindgen_cli_support::Bindgen::new()
121        .input_path(&wasm)
122        .out_name(&opts.bin)
123        .web(true)
124        .map_err(|e| format!("wasm-bindgen: {e:#}"))?
125        .typescript(false)
126        .omit_default_module_path(false)
127        .remove_name_section(opts.release)
128        .generate(&out_dir)
129        .map_err(|e| format!("wasm-bindgen: {e:#}"))?;
130
131    optimize(&out_dir.join(format!("{}_bg.wasm", opts.bin)), opts.release)?;
132
133    let template = if manifest.is_some() {
134        let own = manifest_dir.join("index.html");
135        own.exists().then_some(own)
136    } else {
137        let package = root.join("crates").join(opts.package());
138        let session = root.join("crates/loam-app/static/session_page.html");
139        let own = [
140            package.join(format!("{}.html", opts.bin)),
141            package.join("index.html"),
142        ];
143        Some(
144            own.into_iter()
145                .find(|path| path.exists())
146                .unwrap_or(session),
147        )
148    };
149    if let Some(template) = template {
150        let dest = out_dir.join("index.html");
151        let same = std::fs::canonicalize(&dest)
152            .is_ok_and(|d| std::fs::canonicalize(&template).is_ok_and(|t| t == d));
153        if same {
154            return Err(format!(
155                "--out-dir would overwrite the template {}",
156                template.display()
157            ));
158        }
159        let page = render_page(&root, &manifest_dir, &template, opts)?;
160        std::fs::write(dest, page).map_err(|e| format!("write index.html: {e}"))?;
161    } else {
162        println!(
163            "index.html skipped: no {}",
164            manifest_dir.join("index.html").display()
165        );
166    }
167
168    let entries =
169        std::fs::read_dir(&out_dir).map_err(|e| format!("read {}: {e}", out_dir.display()))?;
170    for entry in entries.flatten() {
171        let size = entry.metadata().map_or(0, |meta| meta.len());
172        println!("{} ({size} bytes)", entry.path().display());
173    }
174    Ok(())
175}
176
177// Shipped wasm must not carry build-machine paths; rustc applies the last matching remap.
178fn remap_paths(command: &mut Command, root: &Path) -> Result<(), String> {
179    let home = std::env::home_dir();
180    let cargo_home = std::env::var_os("CARGO_HOME")
181        .map(PathBuf::from)
182        .or_else(|| home.as_ref().map(|home| home.join(".cargo")));
183    let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string());
184    let sysroot = Command::new(rustc)
185        .args(["--print", "sysroot"])
186        .output()
187        .map_err(|e| format!("rustc --print sysroot: {e}"))?;
188    let sysroot = PathBuf::from(String::from_utf8_lossy(&sysroot.stdout).trim());
189    let flags: Vec<String> = [
190        (home, "~"),
191        (cargo_home, "cargo"),
192        (Some(sysroot), "rust"),
193        (Some(root.to_path_buf()), "loam"),
194    ]
195    .into_iter()
196    .filter_map(|(from, to)| {
197        from.map(|from| format!("--remap-path-prefix={}={to}", from.display()))
198    })
199    .collect();
200    // Cargo ignores config rustflags whenever the environment sets any.
201    let set = std::env::var("CARGO_ENCODED_RUSTFLAGS").ok().or_else(|| {
202        std::env::var("RUSTFLAGS")
203            .ok()
204            .map(|flags| flags.split_whitespace().collect::<Vec<_>>().join("\u{1f}"))
205    });
206    match set {
207        Some(set) => {
208            let all: Vec<String> = std::iter::once(set)
209                .filter(|set| !set.is_empty())
210                .chain(flags)
211                .collect();
212            command.env("CARGO_ENCODED_RUSTFLAGS", all.join("\u{1f}"));
213        }
214        None => {
215            let array = serde_json::to_string(&flags).map_err(|e| format!("remap flags: {e}"))?;
216            command
217                .arg("--config")
218                .arg(format!("target.wasm32-unknown-unknown.rustflags={array}"));
219        }
220    }
221    Ok(())
222}
223
224fn wasm_artifact(messages: impl BufRead, bin: &str) -> Result<PathBuf, String> {
225    let mut found = None;
226    for line in messages.lines() {
227        let line = line.map_err(|e| format!("read cargo output: {e}"))?;
228        let Ok(message) = serde_json::from_str::<serde_json::Value>(&line) else {
229            continue;
230        };
231        if message["reason"] != "compiler-artifact" {
232            continue;
233        }
234        let target = &message["target"];
235        let is_bin = target["kind"]
236            .as_array()
237            .is_some_and(|kinds| kinds.iter().any(|kind| kind == "bin"));
238        if !is_bin || target["name"] != bin {
239            continue;
240        }
241        if let Some(path) = message["executable"].as_str() {
242            found = Some(PathBuf::from(path));
243        }
244    }
245    found.ok_or_else(|| format!("cargo built no bin named {bin}"))
246}
247
248fn optimize(wasm: &Path, release: bool) -> Result<(), String> {
249    if !release {
250        println!("wasm-opt skipped: not a --release build");
251        return Ok(());
252    }
253    let optimized = wasm.with_extension("opt.wasm");
254    let result = Command::new("wasm-opt")
255        .args(["-Oz", "-o"])
256        .arg(&optimized)
257        .arg(wasm)
258        .status();
259    match result {
260        Ok(status) if status.success() => std::fs::rename(&optimized, wasm)
261            .map_err(|e| format!("replace {}: {e}", wasm.display())),
262        Ok(status) => Err(format!("wasm-opt failed: {status}")),
263        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
264            println!("wasm-opt skipped: not on PATH");
265            Ok(())
266        }
267        Err(e) => Err(format!("run wasm-opt: {e}")),
268    }
269}
270
271fn render_page(
272    root: &Path,
273    manifest_dir: &Path,
274    template: &Path,
275    opts: &Options,
276) -> Result<String, String> {
277    let read = |path: &Path| {
278        std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))
279    };
280    let template = read(template)?;
281    let loader = read(&root.join("crates/loam-app/static/page_loader.html"))?;
282    let hash = git_hash(manifest_dir);
283    Ok(template
284        .replace("{{LOAM_PAGE_LOADER}}", &loader)
285        .replace("{{LOAM_PUBLIC_URL}}", &opts.public_url)
286        .replace("{{LOAM_BIN}}", &opts.bin)
287        .replace("{{LOAM_HASH}}", &hash))
288}
289
290fn git_hash(dir: &Path) -> String {
291    let git = |args: &[&str]| -> Option<String> {
292        let out = Command::new("git")
293            .current_dir(dir)
294            .args(args)
295            .output()
296            .ok()
297            .filter(|out| out.status.success())?;
298        Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
299    };
300    let Some(hash) = git(&["rev-parse", "--short", "HEAD"]) else {
301        return "nogit".to_string();
302    };
303    let dirty = git(&["status", "--porcelain"]).is_some_and(|status| !status.is_empty());
304    if dirty {
305        format!("{hash}-dirty")
306    } else {
307        hash
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::wasm_artifact;
314
315    #[test]
316    fn wasm_artifact_ignores_other_units() {
317        let unit = |name: &str, kind: &str, executable: serde_json::Value| {
318            serde_json::json!({
319                "reason": "compiler-artifact",
320                "target": { "name": name, "kind": [kind] },
321                "executable": executable,
322            })
323            .to_string()
324        };
325        let lines = [
326            r#"{"reason":"build-script-executed","package_id":"examples"}"#.to_string(),
327            unit(
328                "build-script-build",
329                "custom-build",
330                "t/build/examples/build-script".into(),
331            ),
332            unit("loam_app", "lib", serde_json::Value::Null),
333            unit("hero", "bin", "t/wasm-release/hero.wasm".into()),
334            unit("backdrop", "bin", "t/wasm-release/backdrop.wasm".into()),
335            r#"{"reason":"build-finished","success":true}"#.to_string(),
336        ];
337        let wasm = wasm_artifact(lines.join("\n").as_bytes(), "backdrop").unwrap();
338        assert_eq!(wasm, std::path::Path::new("t/wasm-release/backdrop.wasm"));
339    }
340}