use crate::core::backend::GeneratedFile;
use crate::core::config::ResolvedCrateConfig;
use crate::core::ir::ApiSurface;
use crate::core::template_versions as tv;
use crate::scaffold::scaffold_meta;
use anyhow::Context as _;
use regex::Regex;
use std::path::{Path, PathBuf};
pub(crate) fn scaffold_wasm(api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
let meta = scaffold_meta(config);
let version = &api.version;
let core_crate_dir = config.core_crate_dir();
let mut files = vec![];
let wasm_pkg_name = config.wasm_package_name();
let core_crate_file = core_crate_dir.replace('-', "_");
let repository_block = meta
.configured_repository
.as_deref()
.map(|repository| {
format!(
r#",
"repository": {{
"type": "git",
"url": "{repository}",
"directory": "crates/{core_crate_dir}-wasm"
}}"#
)
})
.unwrap_or_default();
let license_block = meta
.license
.as_deref()
.map(|license| format!(",\n \"license\": \"{license}\""))
.unwrap_or_default();
let targets = config.wasm_targets();
const VALID_TARGETS: &[&str] = &["web", "bundler", "nodejs", "deno"];
if targets.is_empty() {
anyhow::bail!("[crates.wasm].targets must list at least one wasm-pack target (web, bundler, nodejs, deno)");
}
for target in &targets {
if !VALID_TARGETS.contains(&target.as_str()) {
anyhow::bail!("[crates.wasm].targets: unknown target '{target}' (valid: web, bundler, nodejs, deno)");
}
}
let has = |t: &str| targets.iter().any(|x| x == t);
let node_target = if has("nodejs") { "nodejs" } else { targets[0].as_str() };
let web_target = if has("web") { "web" } else { targets[0].as_str() };
let exports_block = crate::scaffold::template_env::render(
"wasm_package_exports.json.jinja",
minijinja::context! {
node_target => node_target,
web_target => web_target,
crate_file => core_crate_file,
},
);
let files_block = if targets.len() == 1 {
format!("[\"pkg/{}\", \"README.md\"]", targets[0])
} else {
"[\"pkg\", \"*.wasm\", \"*.d.ts\", \"README.md\"]".to_string()
};
let per_target_scripts: String = targets
.iter()
.map(|t| format!(" \"build:wasm:{t}\": \"wasm-pack build --release --target {t} --out-dir pkg/{t}\",\n"))
.collect();
let build_all = targets
.iter()
.map(|t| format!("npm run build:wasm:{t}"))
.collect::<Vec<_>>()
.join(" && ");
let pkg_json = format!(
r#"{{
"name": "{wasm_pkg_name}",
"version": "{version}",
"private": false,
"description": "{description}"{license_block}{repository_block},
"publishConfig": {{
"access": "public"
}},
"type": "module",
"files": {files_block},
"main": "pkg/{node_target}/{core_crate_file}_wasm.js",
"module": "pkg/{web_target}/{core_crate_file}_wasm.js",
"types": "pkg/{node_target}/{core_crate_file}_wasm.d.ts",
{exports_block} "engines": {{
"node": "{node_engine}"
}},
"scripts": {{
"build": "wasm-pack build --target {node_target} --out-dir pkg/{node_target}",
"build:ci": "wasm-pack build --release --target {node_target} --out-dir pkg/{node_target}",
{per_target_scripts} "build:all": "{build_all} && find pkg -name .gitignore -delete",
"test": "vitest run",
"test:watch": "vitest watch",
"test:coverage": "vitest run --coverage",
"clean": "rm -rf pkg dist"
}},
"devDependencies": {{
"vitest": "{vitest}",
"@vitest/coverage-v8": "{vitest_coverage_v8}"
}}
}}
"#,
wasm_pkg_name = wasm_pkg_name,
version = version,
description = meta.description,
license_block = license_block,
repository_block = repository_block,
files_block = files_block,
node_target = node_target,
web_target = web_target,
core_crate_file = core_crate_file,
exports_block = exports_block,
node_engine = tv::npm::NODE_ENGINE,
per_target_scripts = per_target_scripts,
build_all = build_all,
vitest = tv::npm::VITEST,
vitest_coverage_v8 = tv::npm::VITEST_COVERAGE_V8,
);
files.push(GeneratedFile {
path: PathBuf::from(format!("crates/{}-wasm/package.json", core_crate_dir)),
content: pkg_json,
generated_header: false,
});
Ok(files)
}
pub(crate) fn migrate_wasm_package_json(base_dir: &Path, relative_path: &Path) -> anyhow::Result<bool> {
let exports_changed = migrate_wasm_package_json_exports(base_dir, relative_path)?;
let vitest_changed = migrate_wasm_package_json_vitest_dev_dependencies(base_dir, relative_path)?;
Ok(exports_changed || vitest_changed)
}
fn migrate_wasm_package_json_exports(base_dir: &Path, relative_path: &Path) -> anyhow::Result<bool> {
let path = crate::cli::pipeline::generate::write::contained_output_path(base_dir, relative_path)?;
let Ok(existing) = std::fs::read_to_string(&path) else {
return Ok(false);
};
let Some(migrated) = repair_missing_wasm_exports(&existing) else {
return Ok(false);
};
if migrated == existing {
return Ok(false);
}
let parent = path
.parent()
.context("wasm package.json path has no parent directory")?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("failed to create temporary file in {}", parent.display()))?;
std::io::Write::write_all(&mut temporary, migrated.as_bytes())
.with_context(|| format!("failed to write temporary file for {}", path.display()))?;
temporary
.persist(&path)
.map_err(|error| error.error)
.with_context(|| format!("failed to replace {}", path.display()))?;
tracing::info!(
path = %path.display(),
"repaired pre-existing crates/*-wasm/package.json: inserted the missing \"exports\" map"
);
Ok(true)
}
fn recognize_alef_wasm_package_json(content: &str) -> Option<(&str, &str, &str)> {
let main_pattern = Regex::new(r#""main":\s*"pkg/([^/"]+)/([^"]+)_wasm\.js""#).expect("valid regex");
let module_pattern = Regex::new(r#""module":\s*"pkg/([^/"]+)/([^"]+)_wasm\.js""#).expect("valid regex");
let types_pattern = Regex::new(r#""types":\s*"pkg/([^/"]+)/([^"]+)_wasm\.d\.ts""#).expect("valid regex");
let main_captures = main_pattern.captures(content)?;
let module_captures = module_pattern.captures(content)?;
let types_captures = types_pattern.captures(content)?;
let node_target = main_captures.get(1)?.as_str();
let crate_file = main_captures.get(2)?.as_str();
let web_target = module_captures.get(1)?.as_str();
if module_captures.get(2)?.as_str() != crate_file {
return None;
}
if types_captures.get(1)?.as_str() != node_target || types_captures.get(2)?.as_str() != crate_file {
return None;
}
Some((node_target, crate_file, web_target))
}
fn repair_missing_wasm_exports(content: &str) -> Option<String> {
if content.contains("\"exports\":") {
return None;
}
let (node_target, crate_file, web_target) = recognize_alef_wasm_package_json(content)?;
let engines_line_index = content.lines().position(|line| line.trim() == "\"engines\": {")?;
let exports_block = crate::scaffold::template_env::render(
"wasm_package_exports.json.jinja",
minijinja::context! {
node_target => node_target,
web_target => web_target,
crate_file => crate_file,
},
);
let mut export_lines: Vec<String> = exports_block.lines().map(str::to_string).collect();
if let Some(first) = export_lines.first_mut() {
*first = format!(" {first}");
}
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
lines.splice(engines_line_index..engines_line_index, export_lines);
let mut joined = lines.join("\n");
if content.ends_with('\n') {
joined.push('\n');
}
Some(joined)
}
fn migrate_wasm_package_json_vitest_dev_dependencies(base_dir: &Path, relative_path: &Path) -> anyhow::Result<bool> {
let path = base_dir.join(relative_path);
let Ok(existing) = std::fs::read_to_string(&path) else {
return Ok(false);
};
let Some(migrated) = repair_missing_wasm_vitest_dev_dependencies(&existing) else {
return Ok(false);
};
if migrated == existing {
return Ok(false);
}
let parent = path
.parent()
.context("wasm package.json path has no parent directory")?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("failed to create temporary file in {}", parent.display()))?;
std::io::Write::write_all(&mut temporary, migrated.as_bytes())
.with_context(|| format!("failed to write temporary file for {}", path.display()))?;
temporary
.persist(&path)
.map_err(|error| error.error)
.with_context(|| format!("failed to replace {}", path.display()))?;
tracing::info!(
path = %path.display(),
"repaired pre-existing crates/*-wasm/package.json: inserted the missing vitest devDependency"
);
Ok(true)
}
fn repair_missing_wasm_vitest_dev_dependencies(content: &str) -> Option<String> {
recognize_alef_wasm_package_json(content)?;
if !content.contains(r#""test": "vitest run""#) {
return None;
}
let needs_coverage_provider = content.contains(r#""test:coverage":"#);
let parsed: serde_json::Value = serde_json::from_str(content).ok()?;
match parsed.get("devDependencies") {
None => insert_new_dev_dependencies_block(content, needs_coverage_provider),
Some(existing) => {
let existing = existing.as_object()?;
let needs_vitest = !existing.contains_key("vitest");
let needs_coverage = needs_coverage_provider && !existing.contains_key("@vitest/coverage-v8");
if !needs_vitest && !needs_coverage {
return None;
}
insert_into_existing_dev_dependencies(content, needs_vitest, needs_coverage)
}
}
}
fn insert_new_dev_dependencies_block(content: &str, include_coverage_provider: bool) -> Option<String> {
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
let last = lines.len().checked_sub(1)?;
let scripts_close = last.checked_sub(1)?;
if lines[last] != "}" || lines[scripts_close] != " }" {
return None;
}
let mut entries = vec![format!("\"vitest\": \"{}\"", tv::npm::VITEST)];
if include_coverage_provider {
entries.push(format!("\"@vitest/coverage-v8\": \"{}\"", tv::npm::VITEST_COVERAGE_V8));
}
let entry_count = entries.len();
lines[scripts_close] = " },".to_string();
let mut block = vec![" \"devDependencies\": {".to_string()];
for (index, entry) in entries.into_iter().enumerate() {
let suffix = if index + 1 == entry_count { "" } else { "," };
block.push(format!(" {entry}{suffix}"));
}
block.push(" }".to_string());
lines.splice(last..last, block);
let mut joined = lines.join("\n");
if content.ends_with('\n') {
joined.push('\n');
}
Some(joined)
}
fn insert_into_existing_dev_dependencies(content: &str, needs_vitest: bool, needs_coverage: bool) -> Option<String> {
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
let open_index = lines.iter().position(|line| line == " \"devDependencies\": {")?;
let object_is_empty = lines
.get(open_index + 1)
.is_some_and(|line| matches!(line.trim(), "}" | "},"));
let mut entries = Vec::new();
if needs_vitest {
entries.push(format!("\"vitest\": \"{}\"", tv::npm::VITEST));
}
if needs_coverage {
entries.push(format!("\"@vitest/coverage-v8\": \"{}\"", tv::npm::VITEST_COVERAGE_V8));
}
let entry_count = entries.len();
let new_lines: Vec<String> = entries
.into_iter()
.enumerate()
.map(|(index, entry)| {
let omit_comma = object_is_empty && index + 1 == entry_count;
let suffix = if omit_comma { "" } else { "," };
format!(" {entry}{suffix}")
})
.collect();
lines.splice(open_index + 1..open_index + 1, new_lines);
let mut joined = lines.join("\n");
if content.ends_with('\n') {
joined.push('\n');
}
Some(joined)
}
pub(crate) fn wasm_cargo_config_file() -> GeneratedFile {
GeneratedFile {
path: PathBuf::from(".cargo/config.toml"),
content: "[build]\nincremental = true\n\n[target.wasm32-unknown-unknown]\nrustflags = [\"-C\", \"target-feature=+bulk-memory\", \"--cfg\", \"getrandom_backend=\\\"wasm_js\\\"\", \"-C\", \"link-arg=--allow-multiple-definition\"]\n\n[net]\ngit-fetch-with-cli = true\n\n[registries.crates-io]\nprotocol = \"sparse\"\n".to_string(),
generated_header: true,
}
}
pub(crate) const STALE_WASM_CARGO_CONFIG: &str = "[build]\nincremental = true\n\n[target.wasm32-unknown-unknown]\nrustflags = [\"-C\", \"target-feature=+bulk-memory\", \"--cfg\", \"getrandom_backend=\\\"wasm_js\\\"\"]\n\n[net]\ngit-fetch-with-cli = true\n\n[registries.crates-io]\nprotocol = \"sparse\"\n";
pub(crate) fn migrate_wasm_cargo_config_allow_multiple_definition(base_dir: &std::path::Path) -> anyhow::Result<bool> {
let path = crate::cli::pipeline::generate::write::contained_output_path(
base_dir,
std::path::Path::new(".cargo/config.toml"),
)?;
let Ok(existing) = std::fs::read_to_string(&path) else {
return Ok(false);
};
if existing != STALE_WASM_CARGO_CONFIG {
return Ok(false);
}
let replacement = wasm_cargo_config_file().content;
let parent = path
.parent()
.context(".cargo/config.toml path has no parent directory")?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("failed to create temporary file in {}", parent.display()))?;
std::io::Write::write_all(&mut temporary, replacement.as_bytes())
.with_context(|| format!("failed to write temporary file for {}", path.display()))?;
temporary
.persist(&path)
.map_err(|error| error.error)
.with_context(|| format!("failed to replace {}", path.display()))?;
tracing::info!(
path = %path.display(),
"repaired pre-existing .cargo/config.toml: added -C link-arg=--allow-multiple-definition \
to the wasm32-unknown-unknown rustflags"
);
Ok(true)
}
#[cfg(test)]
mod migrate_tests;