islands-build 0.1.3

Layout-agnostic build pipeline for islands.rs apps: WASM bundling, the V8 module-namespace patch, per-page CSS, and content-hash manifests. Composed by a thin xtask in any workspace.
Documentation
//! WASM build orchestration: resolve + install the matching `wasm-bindgen-cli`,
//! compile the runtime and page crates to `wasm32-unknown-unknown`, run
//! `wasm-bindgen --target web`, verify the emitted artifacts, and apply the V8
//! namespace patch.
//!
//! Everything runs in the **plan's** `workspace_root`, so `-p <package>` resolves
//! through the invoking workspace's own cargo graph — no hardcoded sibling path.

use std::fs;
use std::path::Path;
use std::process::Command;

use anyhow::{bail, Context, Result};

use crate::config::{BuildPlan, PageBuild};
use crate::patch;

/// Parse `Cargo.lock` and return the resolved `wasm-bindgen` version. The page
/// and runtime JS shims must be processed by exactly this CLI version.
pub fn read_wasm_bindgen_version(workspace_root: &Path) -> Result<String> {
    let lock_path = workspace_root.join("Cargo.lock");
    let lock_text =
        fs::read_to_string(&lock_path).with_context(|| format!("read {}", lock_path.display()))?;
    let document: toml::Value = lock_text
        .parse()
        .with_context(|| format!("parse {}", lock_path.display()))?;
    let packages = document
        .get("package")
        .and_then(|value| value.as_array())
        .context("Cargo.lock missing [[package]] table")?;
    for package in packages {
        let name = package.get("name").and_then(|value| value.as_str());
        if name == Some("wasm-bindgen") {
            let version = package
                .get("version")
                .and_then(|value| value.as_str())
                .context("wasm-bindgen package has no version field")?;
            return Ok(version.to_owned());
        }
    }
    bail!("wasm-bindgen not found in Cargo.lock at {}", lock_path.display())
}

/// The installed `wasm-bindgen` CLI version, or `None` if absent / unparseable.
fn installed_wasm_bindgen_version() -> Option<String> {
    let output = Command::new("wasm-bindgen").arg("--version").output().ok()?;
    if !output.status.success() {
        return None;
    }
    // Output format: "wasm-bindgen 0.2.121".
    let stdout = String::from_utf8_lossy(&output.stdout);
    stdout.split_whitespace().nth(1).map(str::to_owned)
}

/// Ensure `wasm-bindgen-cli` at exactly `required_version` is installed,
/// installing it via `cargo install` if absent or mismatched. Idempotent: a
/// matching install returns early.
pub fn ensure_wasm_bindgen_cli(required_version: &str) -> Result<()> {
    if let Some(installed) = installed_wasm_bindgen_version() {
        if installed == required_version {
            eprintln!("[islands-build] wasm-bindgen-cli {installed} already installed");
            return Ok(());
        }
        eprintln!(
            "[islands-build] wasm-bindgen-cli version mismatch: installed={installed}, \
             required={required_version}; reinstalling"
        );
    } else {
        eprintln!("[islands-build] wasm-bindgen-cli not found; installing {required_version}");
    }

    let output = Command::new("cargo")
        .args([
            "install",
            "wasm-bindgen-cli",
            "--version",
            required_version,
            "--locked",
        ])
        .output()
        .context("spawn cargo install wasm-bindgen-cli")?;
    if !output.status.success() {
        bail!(
            "cargo install wasm-bindgen-cli --version {required_version} failed (exit {:?}):\n{}",
            output.status.code(),
            String::from_utf8_lossy(&output.stderr)
        );
    }
    eprintln!("[islands-build] wasm-bindgen-cli {required_version} installed");
    Ok(())
}

/// Run `cargo <args>` in `working_dir`, bailing on non-zero exit with stderr.
fn run_cargo(working_dir: &Path, args: &[&str]) -> Result<()> {
    eprintln!("[islands-build] cargo {}", args.join(" "));
    let output = Command::new("cargo")
        .args(args)
        .current_dir(working_dir)
        .output()
        .with_context(|| format!("spawn cargo {}", args.join(" ")))?;
    if !output.status.success() {
        bail!(
            "cargo {} failed (exit {:?}):\n{}",
            args.join(" "),
            output.status.code(),
            String::from_utf8_lossy(&output.stderr)
        );
    }
    Ok(())
}

/// Run `wasm-bindgen --target web` for one bundle.
pub fn wasm_bindgen_bundle(out_dir: &Path, out_name: &str, wasm_input: &Path) -> Result<()> {
    eprintln!(
        "[islands-build] wasm-bindgen → {} ({out_name})",
        out_dir.display()
    );
    let output = Command::new("wasm-bindgen")
        .arg("--target")
        .arg("web")
        .arg("--out-dir")
        .arg(out_dir)
        .arg("--out-name")
        .arg(out_name)
        .arg(wasm_input)
        .output()
        .with_context(|| format!("spawn wasm-bindgen for {}", wasm_input.display()))?;
    if !output.status.success() {
        bail!(
            "wasm-bindgen failed for {} (exit {:?}):\n{}",
            wasm_input.display(),
            output.status.code(),
            String::from_utf8_lossy(&output.stderr)
        );
    }
    Ok(())
}

/// Assert the first four bytes of a `.wasm` file are `\0asm`.
fn verify_wasm_magic(wasm_path: &Path) -> Result<()> {
    let bytes = fs::read(wasm_path).with_context(|| format!("read {}", wasm_path.display()))?;
    let magic = bytes.get(0..4);
    if magic != Some(&[0x00, 0x61, 0x73, 0x6d]) {
        bail!(
            "{} is not a valid WASM module (bad magic header)",
            wasm_path.display()
        );
    }
    Ok(())
}

/// Assert a page JS file imports the shared runtime URL.
fn verify_page_js_imports_core(js_path: &Path) -> Result<()> {
    let content =
        fs::read_to_string(js_path).with_context(|| format!("read {}", js_path.display()))?;
    if !content.contains(patch::RUNTIME_IMPORT_URL) {
        bail!(
            "{} does not import {}",
            js_path.display(),
            patch::RUNTIME_IMPORT_URL
        );
    }
    Ok(())
}

/// Assert the runtime JS does NOT import any `/static/page-*` path (the core must
/// not depend on pages).
fn verify_core_js_no_page_imports(js_path: &Path) -> Result<()> {
    let content =
        fs::read_to_string(js_path).with_context(|| format!("read {}", js_path.display()))?;
    if content.contains("/static/page-") {
        bail!(
            "{} unexpectedly imports a /static/page-* path (core must not depend on pages)",
            js_path.display()
        );
    }
    Ok(())
}

/// Build the shared runtime crate into `islands_core.{js,_bg.wasm}` — Rec 2's
/// build-from-source helper. Compiles `-p <runtime.package>` (nav-gated) for
/// `wasm32-unknown-unknown` in the plan's workspace, runs `wasm-bindgen`, verifies
/// the artifacts, and applies the snippet-namespace patch.
///
/// Independently callable so a consumer can refresh just the runtime bundle.
pub fn build_runtime(plan: &BuildPlan) -> Result<()> {
    let required_version = read_wasm_bindgen_version(&plan.workspace_root)?;
    ensure_wasm_bindgen_cli(&required_version)?;
    build_runtime_inner(plan)
}

/// The runtime build without the `ensure_wasm_bindgen_cli` step (callers that
/// already ensured it — e.g. [`build_wasm`] — use this to avoid a redundant
/// version probe).
fn build_runtime_inner(plan: &BuildPlan) -> Result<()> {
    let mut args = vec!["build"];
    if plan.release {
        args.push("--release");
    }
    args.extend_from_slice(&["--target", "wasm32-unknown-unknown", "-p", &plan.runtime.package]);
    // Cargo only accepts `--features` / `--no-default-features` for a package in the
    // *invoking* workspace. For an external consumer the runtime is a path
    // dependency (it belongs to the islands.rs workspace, never theirs), so cargo
    // rejects the flag with "cannot specify features for packages outside of
    // workspace". Only a member runtime takes the flag; for a non-member, its
    // feature set is resolved from the consumer's dependency declaration (declare
    // `islands-runtime` with `default-features = false` for a nav-free core).
    let runtime_is_member = is_workspace_member(&plan.workspace_root, &plan.runtime.package);
    match runtime_feature_mode(plan.runtime.nav, runtime_is_member) {
        RuntimeFeatureMode::Default => {}
        RuntimeFeatureMode::NoDefaultFeatures => args.push("--no-default-features"),
        RuntimeFeatureMode::DepDriven => {
            eprintln!(
                "[islands-build] runtime_nav = false, but '{package}' is not a member of this \
                 workspace — its feature set is resolved from your `{package}` dependency, not \
                 from this build. Declare it with `default-features = false` for a nav-free core, \
                 and keep runtime_nav = true.",
                package = plan.runtime.package
            );
        }
    }
    run_cargo(&plan.workspace_root, &args)?;

    let artifact = plan
        .wasm_artifact_dir()
        .join(format!("{}.wasm", plan.runtime.package.replace('-', "_")));
    let out_dir = plan.runtime_out_dir();
    wasm_bindgen_bundle(&out_dir, "islands_core", &artifact)?;

    verify_wasm_magic(&out_dir.join("islands_core_bg.wasm"))?;
    let core_js = out_dir.join("islands_core.js");
    verify_core_js_no_page_imports(&core_js)?;
    // The nav feature's inline_js snippets need the same V8 workaround.
    patch::patch_runtime_snippets(&core_js)?;
    Ok(())
}

/// How the runtime's Cargo features are resolved for the from-source build.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RuntimeFeatureMode {
    /// `runtime_nav = true`: build with the runtime's default features (nav on).
    /// No feature flag is passed.
    Default,
    /// `runtime_nav = false` on a workspace-member runtime: pass
    /// `--no-default-features` (the only lever, since nothing else constrains a
    /// directly-built member's features).
    NoDefaultFeatures,
    /// `runtime_nav = false` on a non-member (path-dep) runtime: cargo forbids
    /// feature flags for an out-of-workspace package, so no flag is passed and the
    /// feature set comes from the consumer's dependency declaration.
    DepDriven,
}

/// Decide how to drive the runtime's features. Pure (the membership probe is
/// separated into [`is_workspace_member`]) so it is unit-tested directly.
fn runtime_feature_mode(nav: bool, runtime_is_member: bool) -> RuntimeFeatureMode {
    if nav {
        RuntimeFeatureMode::Default
    } else if runtime_is_member {
        RuntimeFeatureMode::NoDefaultFeatures
    } else {
        RuntimeFeatureMode::DepDriven
    }
}

/// Whether `package` is a member of the workspace rooted at `workspace_root`.
///
/// Uses `cargo metadata --no-deps`, whose `packages` array contains only the
/// invoking workspace's members (dependencies are excluded). Returns `false` when
/// membership cannot be determined — the safe default, since a non-member runtime
/// omits the feature flag and so never trips cargo's "cannot specify features for
/// packages outside of workspace" error.
fn is_workspace_member(workspace_root: &Path, package: &str) -> bool {
    let output = Command::new("cargo")
        .args(["metadata", "--no-deps", "--format-version", "1"])
        .current_dir(workspace_root)
        .output();
    let output = match output {
        Ok(output) if output.status.success() => output,
        _ => return false,
    };
    let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
        return false;
    };
    metadata
        .get("packages")
        .and_then(|packages| packages.as_array())
        .is_some_and(|packages| {
            packages
                .iter()
                .any(|entry| entry.get("name").and_then(|name| name.as_str()) == Some(package))
        })
}

/// Build every page crate and emit its `<underscore>.{js,_bg.wasm}` bundle.
fn build_pages_wasm(plan: &BuildPlan) -> Result<()> {
    if plan.pages.is_empty() {
        return Ok(());
    }
    // One parallel cargo invocation for all pages.
    let mut args: Vec<String> = vec!["build".to_owned()];
    if plan.release {
        args.push("--release".to_owned());
    }
    args.push("--target".to_owned());
    args.push("wasm32-unknown-unknown".to_owned());
    for page in &plan.pages {
        args.push("-p".to_owned());
        args.push(page.crate_name.clone());
    }
    let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
    run_cargo(&plan.workspace_root, &args_ref)?;

    let artifact_dir = plan.wasm_artifact_dir();
    for page in &plan.pages {
        bind_and_patch_page(plan, page, &artifact_dir)?;
    }
    Ok(())
}

/// wasm-bindgen one page, verify its artifacts + core import, then apply and
/// assert the V8 namespace patch.
fn bind_and_patch_page(plan: &BuildPlan, page: &PageBuild, artifact_dir: &Path) -> Result<()> {
    let underscore = page.underscore_name();
    let out_dir = plan.page_out_dir(page);
    wasm_bindgen_bundle(&out_dir, &underscore, &artifact_dir.join(format!("{underscore}.wasm")))?;

    verify_wasm_magic(&out_dir.join(format!("{underscore}_bg.wasm")))?;
    let page_js = out_dir.join(format!("{underscore}.js"));
    verify_page_js_imports_core(&page_js)?;
    patch::patch_page_js(&page_js)?;
    // Loud guard: fail now if wasm-bindgen's output shape drifted past the patch.
    patch::assert_patched(&page_js)?;
    Ok(())
}

/// Build, bindgen, and patch **one** page crate — the incremental-rebuild path
/// (a dev watcher rebuilding a single changed page). Assumes the
/// `wasm-bindgen-cli` is already installed (a full [`build_wasm`] or
/// [`build_runtime`] earlier in the session ensured it).
pub fn build_page_wasm(plan: &BuildPlan, page: &PageBuild) -> Result<()> {
    let mut args = vec!["build"];
    if plan.release {
        args.push("--release");
    }
    args.extend_from_slice(&["--target", "wasm32-unknown-unknown", "-p", &page.crate_name]);
    run_cargo(&plan.workspace_root, &args)?;
    bind_and_patch_page(plan, page, &plan.wasm_artifact_dir())
}

/// Build the runtime then all pages: the full WASM half of the pipeline.
pub fn build_wasm(plan: &BuildPlan) -> Result<()> {
    let required_version = read_wasm_bindgen_version(&plan.workspace_root)?;
    eprintln!("[islands-build] wasm-bindgen version in Cargo.lock: {required_version}");
    ensure_wasm_bindgen_cli(&required_version)?;
    build_runtime_inner(plan)?;
    build_pages_wasm(plan)?;
    eprintln!("[islands-build] build-wasm complete");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{runtime_feature_mode, RuntimeFeatureMode};

    #[test]
    fn nav_on_uses_default_features_regardless_of_membership() {
        assert_eq!(runtime_feature_mode(true, true), RuntimeFeatureMode::Default);
        assert_eq!(runtime_feature_mode(true, false), RuntimeFeatureMode::Default);
    }

    #[test]
    fn nav_off_member_passes_no_default_features() {
        // In-repo (member): the flag is the only lever for a directly-built runtime.
        assert_eq!(
            runtime_feature_mode(false, true),
            RuntimeFeatureMode::NoDefaultFeatures
        );
    }

    #[test]
    fn nav_off_non_member_is_dependency_driven() {
        // External consumer (path-dep): cargo forbids the flag, so features come
        // from the consumer's `islands-runtime` dependency declaration (R2-1).
        assert_eq!(
            runtime_feature_mode(false, false),
            RuntimeFeatureMode::DepDriven
        );
    }
}