crates/nornir-build-thing/src/wasm.rs
//! **The wasm cell: a cargo build, plus the TYPED STAGES that turn its output into
//! a loadable bundle.**
//!
//! # The gap, measured
//!
//! `ArtifactKind::Wasm` is a cargo cell by construction — `validate_kind` requires a
//! `wasm32` target and the target rides `cargo_args`. That is correct and it is also
//! not enough, because **what cargo produces is not what a consumer loads**.
//! Measured on facett, 2026-09-06:
//!
//! ```text
//! cargo build -p facet-wrapped --target wasm32-unknown-unknown --release
//! → facet_wrapped.wasm 11 647 012 B ONE file
//!
//! what the wheel actually stages:
//! → facet_wrapped_bg.wasm 10 116 976 B a DIFFERENT, rewritten file
//! → facet_wrapped.js 107 555 B glue cargo has never heard of
//! → manifest.json 4 682 B printed by the crate itself
//! ```
//!
//! So chaining a plain cargo variant into a package cell with `from_variant` cannot
//! work: it would build an 11.6 MB file nothing consumes and then refuse for the
//! same three missing inputs, one step later and **with a success in between**.
//! facett's own declaration says so, and this module is what makes the sentence
//! stop being true.
//!
//! # Why these are TYPED stages and not a command
//!
//! The obvious shape is a `command = "…"` field, and it is the wrong road: a build
//! declaration with a command in it is a shell script with extra steps, and this
//! constellation spent 2026-09-06 removing shell scripts. There is no command field
//! here and `tests/wasm_stage.rs` asserts on the emitted TEXT that there is none.
//!
//! [`PackageFormat::Pip`](crate::package::PackageFormat) is the precedent. The forge
//! knows what pip packaging IS; it does not run a string. These are the same kind of
//! object:
//!
//! * [`BindgenStage`] — the output target is an ENUM, not a string; the outputs are
//! DERIVED from the package name (`facet-wrapped` → `facet_wrapped.js` +
//! `facet_wrapped_bg.wasm`), so the `_bg` split is something the stage KNOWS
//! rather than something a human spells and can spell wrong; and the tool's
//! VERSION is not declared at all — it comes from `Cargo.lock`
//! ([`nornir_xtask_support::locked_wasm_bindgen_version`]), because a bindgen CLI
//! that does not match the lock is the classic silent blank canvas.
//! * [`ComponentManifestStage`] — a cargo EXAMPLE name and the file its stdout is
//! captured into. `example = "manifest"` is a cargo target name, the same kind of
//! thing as `bin = "korp-server"` on a variant; the forge builds the argv.
//!
//! # Honest scope
//!
//! The declaration, its validation, the derived output names and the orchestration
//! are REAL and tested here with fakes — no toolchain, no network. RUNNING the
//! stages is the [`WasmStageRunner`] seam. The live leg
//! (`XtaskWasmStageRunner`, feature `wasm-stage`) drives
//! [`nornir_xtask_support`]'s `ensure_wasm_target` / `ensure_wasm_bindgen` /
//! `locked_wasm_bindgen_version` — reused, not re-derived.
//!
//! **It runs host-side, and that is a departure worth stating.** The stage could
//! have gone in a pinned container image the way pip packaging did. It does not,
//! for three reasons: the pin that matters is bindgen-vs-`Cargo.lock` and an image
//! would have to match the lock anyway, so the lock is the authority either way; the
//! wasm cell's own cargo build already runs on the host, so a containerised bindgen
//! would have to bind the host target directory in — and since edda `bd04bf2` that
//! directory is wherever `build.target-dir` says, not `<root>/target`; and putting
//! the tool in the pip image is a different road that was not the one ruled. The
//! seam is what makes a container leg a later drop-in rather than a rewrite.
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
/// **What shape of JS glue `wasm-bindgen` emits** — an enum, because a string here
/// would be a spelling nobody checks until the bundle does not load.
///
/// These are wasm-bindgen's own `--target` values. `Web` is what a browser
/// `<script type="module">` loads directly and is what every bundle in this
/// constellation uses today.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BindgenTarget {
/// Native ES modules, loaded straight by a browser.
#[default]
Web,
/// For a JS bundler (webpack/rollup) to consume.
Bundler,
/// A classic script that defines a global — no module system.
NoModules,
/// CommonJS, for node.
Nodejs,
/// Deno.
Deno,
}
impl BindgenTarget {
/// The `--target` value wasm-bindgen takes. The ONE place the spelling lives.
pub fn as_str(self) -> &'static str {
match self {
BindgenTarget::Web => "web",
BindgenTarget::Bundler => "bundler",
BindgenTarget::NoModules => "no-modules",
BindgenTarget::Nodejs => "nodejs",
BindgenTarget::Deno => "deno",
}
}
}
/// The cdylib artifact rename cargo applies: `facet-wrapped` → `facet_wrapped`.
fn crate_stem(package: &str) -> String {
package.replace('-', "_")
}
/// **The file stem of the module cargo writes for `variant`** — and so the stem
/// wasm-bindgen names its outputs after (`<stem>.js`, `<stem>_bg.wasm`; bindgen
/// keeps its INPUT's stem). An EXAMPLE and a BIN keep their names as written
/// (`facett-demo` → `facett-demo.wasm`); only a cdylib carries cargo's `-` → `_`
/// rename (`facet-wrapped` → `facet_wrapped.wasm`).
///
/// MEASURED 2026-09-29 (dwarf, facett `facett-demo-wasm`): with the stem always
/// `crate_stem(package)`, a bin row looked for `facett_demo.wasm` beside the
/// `facett-demo.wasm` cargo had just written and refused to bindgen it.
pub fn artifact_stem(variant: &crate::variant::BuildVariant) -> String {
match (&variant.example, &variant.bin) {
(Some(e), _) => e.clone(),
(None, Some(b)) => b.clone(),
(None, None) => crate_stem(variant.package.as_deref().unwrap_or(&variant.name)),
}
}
/// **The wasm-bindgen stage.** Named fields, no command line.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct BindgenStage {
/// The JS shape to emit.
#[serde(default)]
pub target: BindgenTarget,
/// Emit `.d.ts` typings. Default `false` — every bundle here passes
/// `--no-typescript`, and a `.d.ts` nobody consumes is a file in the release
/// that no declaration accounts for.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub typescript: bool,
}
impl BindgenStage {
/// The raw cargo artifact this stage CONSUMES — `<stem>.wasm`.
///
/// A different file from [`wasm_file`](Self::wasm_file), and that difference is
/// the whole reason this stage exists: if they were the same, chaining a plain
/// cargo variant would have worked.
pub fn cargo_wasm_file(&self, package: &str) -> String {
format!("{}.wasm", crate_stem(package))
}
/// The rewritten wasm this stage PRODUCES — `<stem>_bg.wasm`.
///
/// The `_bg` suffix is wasm-bindgen's, and it is derived here rather than
/// declared so a recipe cannot spell it wrong — a misspelled output is a wheel
/// that stages a file that was never written and loads nothing.
pub fn wasm_file(&self, package: &str) -> String {
format!("{}_bg.wasm", crate_stem(package))
}
/// The JS glue module this stage produces — `<stem>.js`.
pub fn js_file(&self, package: &str) -> String {
format!("{}.js", crate_stem(package))
}
/// Both produced filenames, in a stable order.
pub fn outputs(&self, package: &str) -> Vec<String> {
vec![self.wasm_file(package), self.js_file(package)]
}
}
/// **The component-manifest stage** — the crate emitting a description OF ITSELF.
///
/// `facet-wrapped/examples/manifest.rs` is ten lines and prints
/// `Registry::with_builtins().manifest_json()` on stdout; a loader reads it to
/// enumerate components without instantiating the module first. It is a cargo
/// EXAMPLE, not a command: `example` names a cargo target the same way `bin` does on
/// a variant, and the forge builds the argv.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ComponentManifestStage {
/// The cargo example (of the cell's own package) that prints the manifest.
pub example: String,
/// The file its stdout is captured into, under the cell's `out_dir`.
pub file: String,
}
/// **A wasm cell** — `[[build.wasm]]`, claimed by the `kind = "wasm"` variant of the
/// SAME NAME (the iso/installer/kernel/package rule; there is no `wasm_ref`).
///
/// The cell is OPTIONAL and its absence is meaningful rather than a defect: every
/// `kind = "wasm"` variant that predates this row declares none, and for those the
/// kind means exactly what it always meant — a plain cargo wasm build. A cell says
/// "and then these typed stages".
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct WasmBuild {
/// The cell handle — the same name as the variant.
pub name: String,
/// Repo-relative directory the bundle lands in. Rebuilt on every run, so a
/// stale file from a previous shape cannot ride into a consumer.
pub out_dir: String,
/// The wasm-bindgen stage.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bindgen: Option<BindgenStage>,
/// The component-manifest stage.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest: Option<ComponentManifestStage>,
}
impl WasmBuild {
/// The absolute bundle directory.
pub fn out_dir(&self, repo_root: &Path) -> PathBuf {
repo_root.join(&self.out_dir)
}
/// **Every file this cell produces, repo-relative** — what a consumer may
/// declare as an input, and what a chain is checked against.
///
/// `variant` supplies the package name the bindgen outputs are derived from; a
/// variant with no `package` falls back to its own name, which is the same rule
/// `cargo_args` uses.
pub fn outputs(&self, variant: &crate::variant::BuildVariant) -> Vec<String> {
let mut out = Vec::new();
if self.bindgen.is_some() {
// Named after the module bindgen is FED ([`artifact_stem`]): a cdylib's
// `_` stem, a bin's or an example's name as written.
let stem = artifact_stem(variant);
for f in [format!("{stem}_bg.wasm"), format!("{stem}.js")] {
out.push(format!("{}/{f}", self.out_dir));
}
}
if let Some(m) = &self.manifest {
out.push(format!("{}/{}", self.out_dir, m.file));
}
out
}
/// Shape-only validation.
pub fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
return Err(anyhow!("wasm build: empty name"));
}
if self.out_dir.trim().is_empty() {
return Err(anyhow!(
"wasm '{}': `out_dir` is REQUIRED — it is where the bundle lands and \
what a consumer declares as its inputs",
self.name
));
}
if Path::new(&self.out_dir).is_absolute() {
return Err(anyhow!(
"wasm '{}': `out_dir` {:?} is absolute — it must be repo-relative, so \
the same declaration means the same directory in every checkout",
self.name,
self.out_dir
));
}
if let Some(m) = &self.manifest {
if m.example.trim().is_empty() {
return Err(anyhow!(
"wasm '{}': the manifest stage names no `example` — it runs a cargo \
example of this cell's own package and captures its stdout, and \
there is no target to run",
self.name
));
}
if m.file.trim().is_empty() {
return Err(anyhow!(
"wasm '{}': the manifest stage names no `file` to capture into",
self.name
));
}
}
if self.bindgen.is_none() && self.manifest.is_none() {
return Err(anyhow!(
"wasm '{}': the cell declares no stage, so it produces nothing beyond \
the raw cargo artifact — and a cell that promises a product it does \
not make is exactly the defect this kind exists to end. Either \
declare a stage, or drop the cell and let the variant be the plain \
cargo wasm build it already is",
self.name
));
}
Ok(())
}
/// The canonical byte string this cell's content id is taken over — every axis
/// that changes the produced BUNDLE.
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut s = format!("wasm#name:{}#out:{}", self.name, self.out_dir);
if let Some(b) = &self.bindgen {
s.push_str(&format!("#bindgen:{}#ts:{}", b.target.as_str(), b.typescript));
}
if let Some(m) = &self.manifest {
s.push_str(&format!("#manifest:{}:{}", m.example, m.file));
}
s.into_bytes()
}
/// The content id of this declaration.
pub fn content_id(&self) -> String {
nornir_hash::sha256_hex(&self.canonical_bytes())
}
}
/// **What one stage needs run** — as DATA, so a test can assert on it without a
/// toolchain, and so there is exactly one place the argv is built.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WasmStageRun {
/// Run wasm-bindgen over the cargo artifact.
Bindgen {
/// The raw `<stem>.wasm` cargo wrote.
input: PathBuf,
/// The bundle directory.
out_dir: PathBuf,
/// The `--target` value.
target: &'static str,
/// Emit typings.
typescript: bool,
},
/// Run a cargo example and capture its stdout into a file.
ComponentManifest {
/// The package the example belongs to.
package: String,
/// The example's cargo target name.
example: String,
/// Where stdout is written.
out_file: PathBuf,
},
}
/// **The seam the toolchain fills.** Two methods, so a test injects a fake and
/// proves the orchestration and every red arm with no wasm target installed.
pub trait WasmStageRunner {
/// One line naming what runs the stages — recorded in the build log.
fn describe(&self) -> String;
/// Run one stage.
fn run(&self, run: &WasmStageRun, repo_root: &Path) -> Result<()>;
}
/// **Drive a cell's stages, in order, and return the files it produced.**
///
/// The order is bindgen then manifest, and it is not arbitrary: the manifest stage
/// writes INTO the bundle directory that bindgen populates, and bindgen's own
/// `--out-dir` is rebuilt fresh on every run (a stale `.js` from a previous shape
/// is exactly the sort of thing that loads and misbehaves). Running the manifest
/// first would have it deleted.
pub fn run_stages(
cell: &WasmBuild,
variant: &crate::variant::BuildVariant,
cargo_wasm: &Path,
repo_root: &Path,
runner: &dyn WasmStageRunner,
) -> Result<Vec<PathBuf>> {
cell.validate()?;
let package = variant.package.as_deref().unwrap_or(&variant.name).to_string();
let out_dir = cell.out_dir(repo_root);
if let Some(b) = &cell.bindgen {
if !cargo_wasm.is_file() {
return Err(anyhow!(
"wasm '{}': the cargo artifact {} is not here — the cell's stages run \
AFTER the cargo build of this variant, and there is nothing to \
bindgen",
cell.name,
cargo_wasm.display()
));
}
// Fresh, so a file from a previous shape cannot survive into the bundle.
if out_dir.exists() {
std::fs::remove_dir_all(&out_dir)
.map_err(|e| anyhow!("clear {}: {e}", out_dir.display()))?;
}
std::fs::create_dir_all(&out_dir)
.map_err(|e| anyhow!("create {}: {e}", out_dir.display()))?;
runner.run(
&WasmStageRun::Bindgen {
input: cargo_wasm.to_path_buf(),
out_dir: out_dir.clone(),
target: b.target.as_str(),
typescript: b.typescript,
},
repo_root,
)?;
}
if let Some(m) = &cell.manifest {
std::fs::create_dir_all(&out_dir)
.map_err(|e| anyhow!("create {}: {e}", out_dir.display()))?;
runner.run(
&WasmStageRun::ComponentManifest {
package: package.clone(),
example: m.example.clone(),
out_file: out_dir.join(&m.file),
},
repo_root,
)?;
}
// READ BACK. "The tool exited 0" is not evidence a file exists, and a bundle
// missing a member is a consumer that stages a file nobody wrote.
let mut produced = Vec::new();
let mut missing = Vec::new();
for rel in cell.outputs(variant) {
let p = repo_root.join(&rel);
match std::fs::metadata(&p) {
Ok(md) if md.len() > 0 => produced.push(p),
Ok(_) => missing.push(format!("{rel} (0 bytes)")),
Err(_) => missing.push(rel),
}
}
if !missing.is_empty() {
return Err(anyhow!(
"wasm '{}': the stages exited 0 but the bundle is missing {} — a consumer \
declaring these as inputs would stage a file nobody wrote",
cell.name,
missing.join(", ")
));
}
Ok(produced)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::variant::{ArtifactKind, BuildVariant};
use std::cell::RefCell;
fn a_cell() -> WasmBuild {
WasmBuild {
name: "facet-wrapped-wasm".into(),
out_dir: "facet-wrapped/dist".into(),
bindgen: Some(BindgenStage { target: BindgenTarget::Web, typescript: false }),
manifest: Some(ComponentManifestStage {
example: "manifest".into(),
file: "manifest.json".into(),
}),
}
}
fn a_variant() -> BuildVariant {
BuildVariant {
name: "facet-wrapped-wasm".into(),
kind: ArtifactKind::Wasm,
package: Some("facet-wrapped".into()),
target: Some("wasm32-unknown-unknown".into()),
..BuildVariant::default()
}
}
fn scratch(tag: &str) -> PathBuf {
let base = std::env::var("CARGO_TARGET_TMPDIR")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir());
let d = base.join(format!("wasmstage-{tag}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A runner that records what it was asked to do and writes what the real tools
/// would have written.
struct FakeRunner {
seen: RefCell<Vec<WasmStageRun>>,
write: bool,
}
impl WasmStageRunner for FakeRunner {
fn describe(&self) -> String {
"a fake".into()
}
fn run(&self, run: &WasmStageRun, _root: &Path) -> Result<()> {
self.seen.borrow_mut().push(run.clone());
if !self.write {
return Ok(());
}
match run {
WasmStageRun::Bindgen { out_dir, .. } => {
std::fs::write(out_dir.join("facet_wrapped_bg.wasm"), b"\0asm\x01\0\0\0")?;
std::fs::write(out_dir.join("facet_wrapped.js"), b"export default 1;")?;
}
WasmStageRun::ComponentManifest { out_file, .. } => {
std::fs::write(out_file, b"{\"components\":[]}")?;
}
}
Ok(())
}
}
/// The happy path, and every fact about the run a reader would want asserted.
#[test]
fn the_stages_run_in_order_and_the_bundle_is_read_back() {
let root = scratch("run");
let cargo_wasm = root.join("facet_wrapped.wasm");
std::fs::write(&cargo_wasm, b"\0asm raw").unwrap();
let runner = FakeRunner { seen: RefCell::new(Vec::new()), write: true };
let produced =
run_stages(&a_cell(), &a_variant(), &cargo_wasm, &root, &runner).expect("runs");
let names: Vec<String> = produced
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["facet_wrapped_bg.wasm", "facet_wrapped.js", "manifest.json"]);
let seen = runner.seen.borrow();
assert_eq!(seen.len(), 2, "both stages must run");
// BINDGEN FIRST — the manifest writes into the directory bindgen rebuilds,
// so the other order deletes it.
match &seen[0] {
WasmStageRun::Bindgen { input, target, typescript, .. } => {
assert_eq!(input, &cargo_wasm);
assert_eq!(*target, "web");
assert!(!typescript);
}
other => panic!("bindgen did not run first: {other:?}"),
}
match &seen[1] {
WasmStageRun::ComponentManifest { package, example, out_file } => {
assert_eq!(package, "facet-wrapped");
assert_eq!(example, "manifest");
assert!(out_file.ends_with("facet-wrapped/dist/manifest.json"));
}
other => panic!("the manifest stage did not run second: {other:?}"),
}
}
/// **The read-back is not decorative.** A runner that exits 0 and writes nothing
/// is the exact false success this whole lane keeps finding.
#[test]
fn a_stage_that_exits_zero_and_writes_nothing_is_refused_by_name() {
let root = scratch("nothing");
let cargo_wasm = root.join("facet_wrapped.wasm");
std::fs::write(&cargo_wasm, b"\0asm raw").unwrap();
let runner = FakeRunner { seen: RefCell::new(Vec::new()), write: false };
let e = format!(
"{:#}",
run_stages(&a_cell(), &a_variant(), &cargo_wasm, &root, &runner)
.expect_err("must refuse")
);
assert!(e.contains("exited 0"), "{e}");
for f in ["facet_wrapped_bg.wasm", "facet_wrapped.js", "manifest.json"] {
assert!(e.contains(f), "the refusal does not name {f}: {e}");
}
}
/// A 0-byte output is a missing output. bindgen writing an empty `.wasm` and the
/// wheel packaging it is how a green build ships a blank canvas.
#[test]
fn a_zero_byte_output_is_a_missing_output() {
struct Empty;
impl WasmStageRunner for Empty {
fn describe(&self) -> String {
"empty".into()
}
fn run(&self, run: &WasmStageRun, _r: &Path) -> Result<()> {
if let WasmStageRun::Bindgen { out_dir, .. } = run {
std::fs::write(out_dir.join("facet_wrapped_bg.wasm"), b"")?;
std::fs::write(out_dir.join("facet_wrapped.js"), b"x")?;
}
if let WasmStageRun::ComponentManifest { out_file, .. } = run {
std::fs::write(out_file, b"{}")?;
}
Ok(())
}
}
let root = scratch("zero");
let cargo_wasm = root.join("facet_wrapped.wasm");
std::fs::write(&cargo_wasm, b"raw").unwrap();
let e = format!(
"{:#}",
run_stages(&a_cell(), &a_variant(), &cargo_wasm, &root, &Empty).expect_err("refuses")
);
assert!(e.contains("0 bytes"), "{e}");
}
/// The stages run AFTER the cargo build, and say so when it has not happened.
#[test]
fn a_missing_cargo_artifact_is_refused_before_any_tool_runs() {
let root = scratch("nocargo");
let runner = FakeRunner { seen: RefCell::new(Vec::new()), write: true };
let e = format!(
"{:#}",
run_stages(&a_cell(), &a_variant(), &root.join("nope.wasm"), &root, &runner)
.expect_err("must refuse")
);
assert!(e.contains("nothing to bindgen"), "{e}");
assert!(runner.seen.borrow().is_empty(), "a tool ran anyway");
}
/// The bundle directory is REBUILT, so a file from a previous shape cannot
/// survive into a consumer.
#[test]
fn a_stale_file_from_a_previous_shape_does_not_survive() {
let root = scratch("stale");
let cargo_wasm = root.join("facet_wrapped.wasm");
std::fs::write(&cargo_wasm, b"raw").unwrap();
let dist = root.join("facet-wrapped/dist");
std::fs::create_dir_all(&dist).unwrap();
std::fs::write(dist.join("old_shape.js"), b"stale").unwrap();
let runner = FakeRunner { seen: RefCell::new(Vec::new()), write: true };
run_stages(&a_cell(), &a_variant(), &cargo_wasm, &root, &runner).expect("runs");
assert!(
!dist.join("old_shape.js").exists(),
"a file from a previous bundle shape survived into this one"
);
}
/// The content id moves with every axis that changes the bundle.
#[test]
fn the_content_id_moves_with_what_changes_the_bundle() {
let base = a_cell();
let id = base.content_id();
for mutate in [
(|c: &mut WasmBuild| c.out_dir = "elsewhere".into()) as fn(&mut WasmBuild),
|c: &mut WasmBuild| c.bindgen.as_mut().unwrap().target = BindgenTarget::Bundler,
|c: &mut WasmBuild| c.bindgen.as_mut().unwrap().typescript = true,
|c: &mut WasmBuild| c.manifest.as_mut().unwrap().example = "other".into(),
|c: &mut WasmBuild| c.manifest = None,
] {
let mut m = base.clone();
mutate(&mut m);
assert_ne!(m.content_id(), id, "a changed axis left the content id identical");
}
assert_eq!(base.clone().content_id(), id);
}
/// Every bindgen target spells itself, and the spelling round-trips through
/// serde — a `--target` value that serialised differently from what the tool
/// takes is a bundle built in the wrong shape.
#[test]
fn every_bindgen_target_spells_itself_and_round_trips() {
for (t, s) in [
(BindgenTarget::Web, "web"),
(BindgenTarget::Bundler, "bundler"),
(BindgenTarget::NoModules, "no-modules"),
(BindgenTarget::Nodejs, "nodejs"),
(BindgenTarget::Deno, "deno"),
] {
assert_eq!(t.as_str(), s);
let json = serde_json::to_string(&t).unwrap();
assert_eq!(json, format!("\"{s}\""), "the serde spelling is not the tool's");
assert_eq!(serde_json::from_str::<BindgenTarget>(&json).unwrap(), t);
}
}
/// The module cargo writes, and the names bindgen gives its outputs, follow the
/// TARGET kind: cdylib `_`, bin as written, example as written under
/// `examples/`. RED-when-broken: the bin case is the facett-demo-wasm defect
/// (looked for `facett_demo.wasm` beside cargo's `facett-demo.wasm`).
#[test]
fn the_stem_follows_the_cargo_target() {
let t = Path::new("/t");
let lib = BuildVariant { profile: crate::variant::Profile::Release, ..a_variant() };
assert_eq!(artifact_stem(&lib), "facet_wrapped");
assert_eq!(
cargo_wasm_path(&lib, t),
PathBuf::from("/t/wasm32-unknown-unknown/release/facet_wrapped.wasm")
);
let bin = BuildVariant { bin: Some("facett-demo".into()), package: Some("facett-demo".into()), ..lib.clone() };
assert_eq!(cargo_wasm_path(&bin, t), PathBuf::from("/t/wasm32-unknown-unknown/release/facett-demo.wasm"));
let cell = WasmBuild { manifest: None, ..a_cell() };
assert_eq!(
cell.outputs(&bin),
vec!["facet-wrapped/dist/facett-demo_bg.wasm".to_string(), "facet-wrapped/dist/facett-demo.js".to_string()]
);
let ex = BuildVariant {
example: Some("panel".into()),
package: Some("facett-wasm-demo".into()),
..lib.clone()
};
assert_eq!(
cargo_wasm_path(&ex, t),
PathBuf::from("/t/wasm32-unknown-unknown/release/examples/panel.wasm")
);
assert_eq!(
cell.outputs(&ex),
vec!["facet-wrapped/dist/panel_bg.wasm".to_string(), "facet-wrapped/dist/panel.js".to_string()]
);
// The cdylib outputs are unchanged by the stem rule.
assert_eq!(
a_cell().outputs(&lib),
vec![
"facet-wrapped/dist/facet_wrapped_bg.wasm".to_string(),
"facet-wrapped/dist/facet_wrapped.js".to_string(),
"facet-wrapped/dist/manifest.json".to_string()
]
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// THE LIVE LEG — feature `wasm-stage`
// ─────────────────────────────────────────────────────────────────────────────
/// **Where cargo wrote the raw artifact these stages consume.**
///
/// `<target-dir>/<triple>/<profile-dir>/<stem>.wasm`, and every part of that is a
/// place to get it wrong, which is why it is computed HERE and not by each caller:
///
/// * the target directory is asked of CARGO
/// ([`nornir_xtask_support::target_dir`], edda `bd04bf2`) rather than assumed to
/// be `<root>/target`. The version that assumed reported a wasm it had just built
/// as MISSING on any box with `build.target-dir` set — oden has
/// `~/cargo-target` — and a tool that cannot find what it just
/// produced teaches everyone who meets it to stop using the tool. Two lanes
/// hand-built on 2026-09-06 partly because of it;
/// * `dev` builds land in `debug`, not in `dev`;
/// * the stem is [`artifact_stem`]: cargo's cdylib `-` → `_` rename, a bin's or an
/// example's name as written (an example under `examples/`).
#[cfg(feature = "wasm-stage")]
pub fn cargo_artifact(variant: &crate::variant::BuildVariant, repo_root: &Path) -> PathBuf {
cargo_wasm_path(variant, &nornir_xtask_support::target_dir(repo_root))
}
/// **The raw module cargo writes for `variant` under `target_dir`** —
/// `<target-dir>/<triple>/<profile-dir>/<file>.wasm`. The pure half of
/// [`cargo_artifact`], for a caller that already knows the target directory
/// (the container road binds it, so it is not cargo's to answer).
///
/// `dev` builds land in `debug`. A BIN target keeps its name as written
/// (`gunnar-ui-web` → `gunnar-ui-web.wasm`); a cdylib carries cargo's `-` → `_`
/// rename (`my-guest` → `my_guest.wasm`), and cargo's json stream reports a
/// cdylib with `executable: null`, so this is how its module is found at all.
pub fn cargo_wasm_path(variant: &crate::variant::BuildVariant, target_dir: &Path) -> PathBuf {
let profile = variant.profile.label();
let profile_dir = if profile == "dev" { "debug" } else { profile };
let dir = target_dir
.join(variant.target.as_deref().unwrap_or(DEFAULT_WASM_TARGET))
.join(profile_dir);
// An example lands one level down, in `examples/`.
let dir = if variant.example.is_some() { dir.join("examples") } else { dir };
dir.join(format!("{}.wasm", artifact_stem(variant)))
}
/// The wasm target every bundle in this constellation builds for.
pub const DEFAULT_WASM_TARGET: &str = "wasm32-unknown-unknown";
/// **The production [`WasmStageRunner`]**, over `nornir-xtask-support`'s toolchain
/// helpers — REUSED, not re-derived.
///
/// * `ensure_wasm_target` — installs the `wasm32-unknown-unknown` rustup target if
/// it is not there, so a first run on a fresh box does not fail on a missing
/// target with a message about a file;
/// * `ensure_wasm_bindgen` — locates (or `cargo install`s) the bindgen CLI **at the
/// version `Cargo.lock` pins**, via `locked_wasm_bindgen_version`. That helper
/// exists because a bindgen CLI that does not match the lock produces the classic
/// silent blank canvas: a bundle that loads, runs, and paints nothing. Pinning
/// here rather than declaring a version in the recipe is deliberate — the lock is
/// the authority, and a second number in a toml is a second number to drift.
///
/// The argv is built HERE, from typed fields. Nothing a recipe author wrote reaches
/// a command line.
#[cfg(feature = "wasm-stage")]
pub struct XtaskWasmStageRunner;
#[cfg(feature = "wasm-stage")]
impl WasmStageRunner for XtaskWasmStageRunner {
fn describe(&self) -> String {
"wasm-bindgen pinned to Cargo.lock (nornir-xtask-support), host-side".to_string()
}
fn run(&self, run: &WasmStageRun, repo_root: &Path) -> Result<()> {
use std::process::Command;
match run {
WasmStageRun::Bindgen { input, out_dir, target, typescript } => {
nornir_xtask_support::ensure_wasm_target()
.map_err(|e| anyhow!("ensure the wasm target: {e}"))?;
let cli = nornir_xtask_support::ensure_wasm_bindgen(repo_root)
.map_err(|e| anyhow!("ensure wasm-bindgen at the locked version: {e}"))?;
let mut cmd = Command::new(&cli);
cmd.arg(input)
.arg("--out-dir")
.arg(out_dir)
.arg("--target")
.arg(target);
if !typescript {
cmd.arg("--no-typescript");
}
nornir_xtask_support::run(&mut cmd, "wasm-bindgen")
.map_err(|e| anyhow!("{e}"))
}
WasmStageRun::ComponentManifest { package, example, out_file } => {
// A cargo EXAMPLE of the cell's own package, run on the host, its
// stdout captured. `example` is a cargo target name — the argv is
// built here, from typed fields.
let out = Command::new(
std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
)
.current_dir(repo_root)
.args(["run", "--quiet", "--example", example, "-p", package])
.output()
.map_err(|e| anyhow!("spawn the `{example}` example of `{package}`: {e}"))?;
if !out.status.success() {
return Err(anyhow!(
"the `{example}` example of `{package}` exited {} — it prints the \
component manifest on stdout and this stage captures it, so a \
non-zero exit means there is no manifest to capture\n{}",
out.status,
String::from_utf8_lossy(&out.stderr)
.lines()
.rev()
.take(20)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n")
));
}
if out.stdout.is_empty() {
return Err(anyhow!(
"the `{example}` example of `{package}` exited 0 and printed \
NOTHING — capturing that would write an empty manifest, and a \
loader reading it would enumerate no components while every \
check passed"
));
}
std::fs::write(out_file, &out.stdout)
.map_err(|e| anyhow!("write {}: {e}", out_file.display()))
}
}
}
}