use std::fs;
use std::path::Path;
use std::process::Command;
use anyhow::{bail, Context, Result};
use crate::config::{BuildPlan, PageBuild};
use crate::patch;
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())
}
fn installed_wasm_bindgen_version() -> Option<String> {
let output = Command::new("wasm-bindgen").arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.split_whitespace().nth(1).map(str::to_owned)
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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)
}
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]);
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)?;
patch::patch_runtime_snippets(&core_js)?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RuntimeFeatureMode {
Default,
NoDefaultFeatures,
DepDriven,
}
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
}
}
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))
})
}
fn build_pages_wasm(plan: &BuildPlan) -> Result<()> {
if plan.pages.is_empty() {
return Ok(());
}
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(())
}
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)?;
patch::assert_patched(&page_js)?;
Ok(())
}
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())
}
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() {
assert_eq!(
runtime_feature_mode(false, true),
RuntimeFeatureMode::NoDefaultFeatures
);
}
#[test]
fn nav_off_non_member_is_dependency_driven() {
assert_eq!(
runtime_feature_mode(false, false),
RuntimeFeatureMode::DepDriven
);
}
}