use super::build::BuildOptions;
use crate::output::{acli_error, acli_error_hints, acli_ok};
use noether_core::stage::{Stage, StageId};
use noether_core::stdlib::load_stdlib;
use noether_engine::checker::{check_graph, verify_signatures};
use noether_engine::lagrange::{collect_stage_ids, parse_graph, resolve_pinning};
use noether_store::StageStore;
use std::collections::HashSet;
use std::path::Path;
use std::process::Command;
const NOETHER_CLI_DIR: &str = env!("CARGO_MANIFEST_DIR");
pub fn cmd_build_browser(store: &dyn StageStore, opts: BuildOptions<'_>) {
let graph_json = match std::fs::read_to_string(opts.graph_path) {
Ok(s) => s,
Err(e) => {
eprintln!(
"{}",
acli_error(&format!("Cannot read '{}': {e}", opts.graph_path))
);
std::process::exit(1);
}
};
let mut graph = match parse_graph(&graph_json) {
Ok(g) => g,
Err(e) => {
eprintln!("{}", acli_error(&format!("Invalid graph JSON: {e}")));
std::process::exit(1);
}
};
if let Err(e) = resolve_pinning(&mut graph.root, store) {
eprintln!("{}", acli_error(&format!("Pinning resolution: {e}")));
std::process::exit(1);
}
match check_graph(&graph.root, store) {
Ok(result) => {
for w in &result.warnings {
eprintln!("Warning: {w}");
}
}
Err(errors) => {
let msgs: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
eprintln!(
"{}",
acli_error_hints(&format!("{} type error(s)", msgs.len()), None, Some(msgs),)
);
std::process::exit(2);
}
}
let stdlib_ids_for_sig: HashSet<StageId> = load_stdlib().into_iter().map(|s| s.id).collect();
let sig_violations: Vec<_> = verify_signatures(&graph.root, store)
.into_iter()
.filter(|v| !stdlib_ids_for_sig.contains(&v.stage_id))
.collect();
if !sig_violations.is_empty() {
let msgs: Vec<String> = sig_violations.iter().map(|v| format!("{v}")).collect();
eprintln!(
"{}",
acli_error_hints(
&format!("{} signature violation(s)", msgs.len()),
None,
Some(msgs),
)
);
std::process::exit(2);
}
let all_ids: Vec<&StageId> = collect_stage_ids(&graph.root);
let stdlib_ids: HashSet<StageId> = stdlib_ids_for_sig;
let mut bundle: Vec<Stage> = Vec::new();
for id in &all_ids {
if stdlib_ids.contains(*id) {
continue;
}
match store.get(id) {
Ok(Some(stage)) => bundle.push(stage.clone()),
Ok(None) => {
eprintln!(
"{}",
acli_error(&format!("Stage '{}' not found in store", id.0))
);
std::process::exit(1);
}
Err(e) => {
eprintln!("{}", acli_error(&format!("Store error loading stage: {e}")));
std::process::exit(1);
}
}
}
let output_path = Path::new(opts.output_path);
let app_name = opts
.app_name
.map(String::from)
.or_else(|| {
output_path
.file_name()
.map(|f| f.to_string_lossy().into_owned())
})
.unwrap_or_else(|| "noether-app".to_string());
let description = opts
.description
.map(String::from)
.unwrap_or_else(|| graph.description.clone());
let app_version = env!("CARGO_PKG_VERSION");
let workspace_root = std::path::PathBuf::from(NOETHER_CLI_DIR)
.join("../..")
.canonicalize()
.unwrap_or_else(|_| std::path::PathBuf::from(NOETHER_CLI_DIR).join("../.."));
let core_path = workspace_root
.join("crates/noether-core")
.display()
.to_string();
let store_path = workspace_root
.join("crates/noether-store")
.display()
.to_string();
let engine_path = workspace_root
.join("crates/noether-engine")
.display()
.to_string();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let build_dir = std::env::temp_dir().join(format!("noether-browser-build-{ts}"));
let src_dir = build_dir.join("src");
if let Err(e) = std::fs::create_dir_all(&src_dir) {
eprintln!(
"{}",
acli_error(&format!("Failed to create build dir: {e}"))
);
std::process::exit(1);
}
let write_file = |rel: &std::path::Path, contents: &str| {
if let Err(e) = std::fs::write(rel, contents) {
eprintln!(
"{}",
acli_error(&format!("Failed to write {}: {e}", rel.display()))
);
std::process::exit(1);
}
};
write_file(&src_dir.join("graph.json"), &graph_json);
write_file(
&src_dir.join("bundle.json"),
&serde_json::to_string_pretty(&bundle).unwrap_or_else(|_| "[]".into()),
);
write_file(
&build_dir.join("Cargo.toml"),
&generate_wasm_cargo_toml(
"noether-browser-stage",
app_version,
&core_path,
&store_path,
&engine_path,
),
);
write_file(&src_dir.join("lib.rs"), &generate_wasm_lib_rs(&bundle));
eprintln!(
"Building {} (browser target, may take a minute on first run)…",
app_name
);
let wasm_pack = which_wasm_pack();
let wasm_status = Command::new(&wasm_pack)
.args(["build", "--target", "web", "--release"])
.current_dir(&build_dir)
.status();
match wasm_status {
Err(e) => {
eprintln!(
"{}",
acli_error(&format!(
"Failed to invoke '{}': {e}\nInstall wasm-pack with: cargo install wasm-pack",
wasm_pack
))
);
let _ = std::fs::remove_dir_all(&build_dir);
std::process::exit(1);
}
Ok(s) if !s.success() => {
eprintln!(
"{}",
acli_error("wasm-pack build failed — see compiler output above")
);
eprintln!("Build directory preserved: {}", build_dir.display());
std::process::exit(1);
}
Ok(_) => {}
}
if let Err(e) = std::fs::create_dir_all(output_path) {
eprintln!(
"{}",
acli_error(&format!(
"Failed to create output dir '{}': {e}",
output_path.display()
))
);
std::process::exit(1);
}
let pkg_dir = build_dir.join("pkg");
let mut wasm_filename = String::from("noether_bg.wasm");
let mut js_filename = String::from("noether.js");
for entry in std::fs::read_dir(&pkg_dir).unwrap_or_else(|_| {
eprintln!(
"{}",
acli_error("wasm-pack did not produce a pkg/ directory")
);
std::process::exit(1);
}) {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let name = entry.file_name().to_string_lossy().to_string();
if name.ends_with("_bg.wasm") {
let _ = std::fs::copy(entry.path(), output_path.join("noether_bg.wasm"));
wasm_filename = "noether_bg.wasm".into();
} else if name.ends_with(".js") && !name.ends_with("_bg.js") && !name.contains("snippets") {
let _ = std::fs::copy(entry.path(), output_path.join("noether.js"));
js_filename = "noether.js".into();
}
}
let html = generate_index_html(
&app_name,
&description,
app_version,
&wasm_filename,
&js_filename,
&graph_json,
&bundle,
);
write_file(&output_path.join("index.html"), &html);
let _ = std::fs::remove_dir_all(&build_dir);
println!(
"{}",
acli_ok(serde_json::json!({
"output_dir": output_path.display().to_string(),
"app_name": app_name,
"version": app_version,
"files": ["index.html", wasm_filename, js_filename],
"stages": {
"bundled": bundle.len(),
"stdlib": all_ids.len() - bundle.len(),
"total": all_ids.len(),
},
"description": description,
}))
);
}
fn which_wasm_pack() -> String {
for candidate in &[
"wasm-pack",
"/home/alpibru/.cargo/bin/wasm-pack",
"/usr/local/bin/wasm-pack",
"/usr/bin/wasm-pack",
] {
if Command::new(candidate).arg("--version").output().is_ok() {
return candidate.to_string();
}
}
if let Ok(home) = std::env::var("HOME") {
let path = format!("{home}/.cargo/bin/wasm-pack");
if Command::new(&path).arg("--version").output().is_ok() {
return path;
}
}
"wasm-pack".into()
}
fn generate_wasm_cargo_toml(
name: &str,
version: &str,
core: &str,
store: &str,
engine: &str,
) -> String {
format!(
r#"[package]
name = "{name}"
version = "{version}"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
noether-core = {{ path = "{core}" }}
noether-store = {{ path = "{store}" }}
noether-engine = {{ path = "{engine}", default-features = false }}
wasm-bindgen = "0.2"
serde_json = "1"
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = {{ version = "0.2", features = ["js"] }}
"#
)
}
fn generate_wasm_lib_rs(bundle: &[Stage]) -> String {
let mut rust_stage_fns = String::new();
let mut dispatch_arms = String::new();
for stage in bundle {
if stage.implementation_language.as_deref() == Some("rust") {
if let Some(code) = &stage.implementation_code {
let fn_name = format!("stage_{}", &stage.id.0[..16].replace('-', "_"));
let indented_code = code
.lines()
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n");
rust_stage_fns.push_str(&format!(
"\n// Stage: {desc}\n#[allow(dead_code, unused_imports)]\nfn {fn_name}(input: &serde_json::Value) -> Result<serde_json::Value, noether_engine::executor::ExecutionError> {{\n{code}\n Ok(execute(input))\n}}\n",
desc = stage.description,
fn_name = fn_name,
code = indented_code,
));
dispatch_arms.push_str(&format!(
" \"{id}\" => {fn_name}(input),\n",
id = stage.id.0,
fn_name = fn_name,
));
}
}
}
let custom_executor_code = if dispatch_arms.is_empty() {
String::from(
r#"
fn make_executor(store: &MemoryStore) -> impl noether_engine::executor::StageExecutor + '_ {
InlineExecutor::from_store(store)
}
"#,
)
} else {
format!(
r#"
struct WasmExecutor {{
inline: InlineExecutor,
}}
impl noether_engine::executor::StageExecutor for WasmExecutor {{
fn execute(
&self,
stage_id: &noether_core::stage::StageId,
input: &serde_json::Value,
) -> Result<serde_json::Value, noether_engine::executor::ExecutionError> {{
match stage_id.0.as_str() {{
{dispatch_arms} _ => self.inline.execute(stage_id, input),
}}
}}
}}
fn make_executor(store: &MemoryStore) -> WasmExecutor {{
WasmExecutor {{ inline: InlineExecutor::from_store(store) }}
}}
"#,
dispatch_arms = dispatch_arms,
)
};
format!(
r#"// Auto-generated by `noether build --target browser`. Do not edit.
use noether_core::stage::Stage;
use noether_core::stdlib::load_stdlib;
use noether_engine::executor::inline::InlineExecutor;
use noether_engine::executor::runner::run_composition;
use noether_engine::lagrange::parse_graph;
use noether_store::{{MemoryStore, StageStore}};
use wasm_bindgen::prelude::*;
use std::sync::OnceLock;
const GRAPH_JSON: &str = include_str!("graph.json");
const BUNDLE_JSON: &str = include_str!("bundle.json");
// ── Custom Rust stage implementations ────────────────────────────────────────
{rust_stage_fns}
// ── Executor ──────────────────────────────────────────────────────────────────
{custom_executor_code}
// ── Lazy-initialised store (bootstrap runs only once per page load) ────────────
static STORE: OnceLock<MemoryStore> = OnceLock::new();
fn get_store() -> &'static MemoryStore {{
STORE.get_or_init(|| {{
let mut store = MemoryStore::new();
for stage in load_stdlib() {{
store.put(stage).ok();
}}
if let Ok(bundle) = serde_json::from_str::<Vec<Stage>>(BUNDLE_JSON) {{
for stage in bundle {{
store.put(stage).ok();
}}
}}
store
}})
}}
/// Execute the embedded composition graph with the given JSON input.
/// Returns JSON-encoded output on success, or a JSON error object on failure.
#[wasm_bindgen]
pub fn execute(input_json: &str) -> String {{
let input: serde_json::Value = match serde_json::from_str(input_json) {{
Ok(v) => v,
Err(e) => {{
return serde_json::json!({{"ok": false, "error": e.to_string()}}).to_string();
}}
}};
let store = get_store();
let graph = match parse_graph(GRAPH_JSON) {{
Ok(g) => g,
Err(e) => {{
return serde_json::json!({{"ok": false, "error": e.to_string()}}).to_string();
}}
}};
let executor = make_executor(store);
match run_composition(&graph.root, &input, &executor, "browser") {{
Ok(result) => serde_json::json!({{"ok": true, "output": result.output}}).to_string(),
Err(e) => serde_json::json!({{"ok": false, "error": e.to_string()}}).to_string(),
}}
}}
/// Execute a single stage by ID with the given JSON input.
/// Used by the JS graph executor in `NoetherRuntime` to run local stages
/// while the JS side handles RemoteStage nodes via `fetch()`.
/// Returns `{{"ok": bool, "output": ...}}`.
#[wasm_bindgen]
pub fn execute_stage(stage_id: &str, input_json: &str) -> String {{
use noether_engine::executor::StageExecutor;
let input: serde_json::Value = match serde_json::from_str(input_json) {{
Ok(v) => v,
Err(e) => {{
return serde_json::json!({{"ok": false, "error": e.to_string()}}).to_string();
}}
}};
let store = get_store();
let executor = make_executor(store);
let id = noether_core::stage::StageId(stage_id.to_string());
match executor.execute(&id, &input) {{
Ok(output) => serde_json::json!({{"ok": true, "output": output}}).to_string(),
Err(e) => serde_json::json!({{"ok": false, "error": format!("{{}}", e)}}).to_string(),
}}
}}
/// Return the full graph JSON string so the JS runtime can traverse all nodes
/// (including `RemoteStage`) and orchestrate execution.
#[wasm_bindgen]
pub fn get_graph_json() -> String {{
GRAPH_JSON.to_string()
}}
/// Return graph metadata as JSON (description).
#[wasm_bindgen]
pub fn get_graph_info() -> String {{
match parse_graph(GRAPH_JSON) {{
Ok(g) => serde_json::json!({{
"description": g.description,
}}).to_string(),
Err(e) => serde_json::json!({{"error": e.to_string()}}).to_string(),
}}
}}
"#,
rust_stage_fns = rust_stage_fns,
custom_executor_code = custom_executor_code,
)
}
fn scope_css(css: &str, prefix: &str) -> String {
let mut out = String::with_capacity(css.len() + 64);
let mut depth: usize = 0;
let mut current_block = String::new();
let chars: Vec<char> = css.chars().collect();
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if ch == '{' {
if depth == 0 {
let selector = current_block.trim();
if selector.starts_with('@') {
out.push_str(selector);
} else {
let scoped = selector
.split(',')
.map(|sel| {
let s = sel.trim();
if s.is_empty() {
String::new()
} else {
format!("{prefix} {s}")
}
})
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(", ");
out.push_str(&scoped);
}
current_block.clear();
}
out.push('{');
depth += 1;
} else if ch == '}' {
out.push('}');
depth = depth.saturating_sub(1);
} else if depth == 0 {
current_block.push(ch);
} else {
out.push(ch);
}
i += 1;
}
out
}
pub fn generate_index_html(
app_name: &str,
description: &str,
version: &str,
wasm_file: &str,
js_file: &str,
graph_json: &str,
bundle: &[noether_core::stage::Stage],
) -> String {
let runtime_js = NOETHER_RUNTIME_JS;
let stage_styles: String = bundle
.iter()
.filter_map(|stage| {
stage.ui_style.as_deref().map(|css| {
let prefix = format!(".nr-{}", &stage.id.0[..8.min(stage.id.0.len())]);
scope_css(css, &prefix)
})
})
.collect::<Vec<_>>()
.join("\n");
let ui = serde_json::from_str::<serde_json::Value>(graph_json)
.ok()
.and_then(|v| v.get("ui").cloned());
let user_style = ui
.as_ref()
.and_then(|u| u.get("style"))
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let atom_js = ui
.as_ref()
.and_then(|u| u.get("atoms"))
.and_then(|a| a.as_object())
.map(|atoms| {
atoms
.iter()
.map(|(k, v)| {
format!(
" runtime.defineAtom({}, {});",
serde_json::to_string(k).unwrap_or_default(),
serde_json::to_string(v).unwrap_or("null".into()),
)
})
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
let event_js = ui
.as_ref()
.and_then(|u| u.get("events"))
.and_then(|e| e.as_object())
.map(|events| {
events
.iter()
.map(|(k, v)| {
let handler = v.as_str().unwrap_or("() => ({})");
format!(
" runtime.defineEvent({}, {});",
serde_json::to_string(k).unwrap_or_default(),
handler,
)
})
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{app_name}</title>
<style>
:root {{
--bg: #0a0d0f; --surface: #111518; --edge: #1c2127; --text: #e8ecf0;
--dim: #7a8896; --accent: #4af4a8; --warn: #f4c84a; --bad: #f46a4a;
--mono: 'Geist Mono', 'JetBrains Mono', 'Fira Code', monospace;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ background: var(--bg); color: var(--text); font-family: var(--mono); font-size: 14px; line-height: 1.5; }}
#noether-root {{ min-height: 100vh; }}
.nr-loading {{ display: flex; align-items: center; justify-content: center; height: 100vh; color: var(--dim); font-size: 13px; }}
.nr-error {{ padding: 24px; color: var(--bad); }}
.nr-error pre {{ margin-top: 8px; font-size: 12px; opacity: 0.7; }}
{stage_styles}
{user_style}
</style>
</head>
<body>
<div id="noether-root"><div class="nr-loading">Loading {app_name}…</div></div>
<script type="module">
import init, {{ execute, execute_stage, get_graph_json, get_graph_info }} from './{js_file}';
{runtime_js}
(async () => {{
try {{
await init('./{wasm_file}');
const info = JSON.parse(get_graph_info());
document.title = info.description || '{app_name}';
const mountEl = document.getElementById('noether-root');
// Pass execute (legacy full-graph), execute_stage (per-stage), and get_graph_json
// to the runtime. The runtime uses execute_stage + get_graph_json to drive
// the JS graph executor, enabling RemoteStage nodes to be handled via fetch().
const runtime = new NoetherRuntime(execute, mountEl, execute_stage, get_graph_json);
// ── Atoms ──
{atom_js}
// ── Events ──
{event_js}
window._noether = runtime;
await runtime.render();
}} catch (err) {{
const root = document.getElementById('noether-root');
root.innerHTML = `<div class="nr-error"><strong>Noether Error</strong><pre>${{err}}</pre></div>`;
console.error('Noether init error:', err);
}}
}})();
</script>
<!-- noether {version} · {description} -->
</body>
</html>"#,
app_name = app_name,
description = description,
version = version,
wasm_file = wasm_file,
js_file = js_file,
runtime_js = runtime_js,
user_style = user_style,
stage_styles = stage_styles,
atom_js = atom_js,
event_js = event_js,
)
}
pub const NOETHER_RUNTIME_JS: &str = include_str!("../noether_runtime.js");