use serde_wasm_bindgen::from_value;
use std::collections::HashSet;
use wasm_bindgen::prelude::*;
static NULL_STATE: serde_json::Value = serde_json::Value::Null;
fn format_parse_errors(errors: &[hypen_parser::error::Rich<char>]) -> String {
errors
.iter()
.map(|e| hypen_parser::error::format_error_simple(e))
.collect::<Vec<_>>()
.join("; ")
}
fn structured_error(error_type: &str, message: &str) -> JsValue {
let obj = js_sys::Object::new();
let _ = js_sys::Reflect::set(&obj, &"type".into(), &JsValue::from_str(error_type));
let _ = js_sys::Reflect::set(&obj, &"message".into(), &JsValue::from_str(message));
obj.into()
}
use crate::{
dispatch::Action,
engine_core::EngineCore,
ir::{ast_to_ir_node, Element, IRNode, NodeId},
lifecycle::ModuleInstance,
reconcile::{create_ir_node_tree_impl, reconcile_ir_node_impl, Patch, ReconcileCtx},
};
#[wasm_bindgen]
pub struct WasmEngine {
core: EngineCore,
patch_callback: Option<js_sys::Function>,
action_handlers: std::collections::HashMap<String, js_sys::Function>,
component_resolver: Option<js_sys::Function>,
import_visited: HashSet<String>,
node_id_index: std::collections::HashMap<String, NodeId>,
data_source_action_handler: Option<js_sys::Function>,
}
#[wasm_bindgen]
impl WasmEngine {
#[allow(clippy::new_without_default)]
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
Self {
core: EngineCore::new(),
patch_callback: None,
action_handlers: std::collections::HashMap::new(),
component_resolver: None,
import_visited: HashSet::new(),
node_id_index: std::collections::HashMap::new(),
data_source_action_handler: None,
}
}
#[wasm_bindgen(js_name = renderSource)]
pub fn render_source(&mut self, source: &str) -> Result<(), JsValue> {
let doc = hypen_parser::parse_document(source)
.map_err(|e| structured_error("parseError", &format_parse_errors(&e)))?;
self.import_visited.clear();
self.resolve_imports(&doc.imports);
if let Some(component) = doc.components.first() {
let ir_node = ast_to_ir_node(component);
self.render(&ir_node);
}
Ok(())
}
#[wasm_bindgen(js_name = setRenderCallback)]
pub fn set_render_callback(&mut self, callback: js_sys::Function) {
self.patch_callback = Some(callback);
}
#[wasm_bindgen(js_name = setComponentResolver)]
pub fn set_component_resolver(&mut self, resolver: js_sys::Function) {
self.component_resolver = Some(resolver);
}
#[wasm_bindgen(js_name = registerPrimitive)]
pub fn register_primitive(&mut self, name: &str) {
self.core.component_registry.register_primitive(name);
}
#[wasm_bindgen(js_name = registerDefaultPrimitives)]
pub fn register_default_primitives(&mut self) {
self.core.component_registry.register_default_primitives();
}
#[wasm_bindgen(js_name = clearResolvedComponents)]
pub fn clear_resolved_components(&mut self) {
self.core.component_registry.clear_resolved();
}
#[wasm_bindgen(js_name = registerResources)]
pub fn register_resources(&mut self, resources_js: JsValue) -> Result<(), JsValue> {
let map: indexmap::IndexMap<String, String> = from_value(resources_js)
.map_err(|e| structured_error("resourceError", &format!("Invalid resources: {}", e)))?;
#[cfg(debug_assertions)]
web_sys::console::log_1(
&format!("[WASM] Registered {} resources", map.len()).into(),
);
self.core.resource_registry.register_map(map);
Ok(())
}
#[wasm_bindgen(js_name = renderLazyComponent)]
pub fn render_lazy_component(&mut self, source: &str) -> Result<(), JsValue> {
self.render_source(source)
}
#[wasm_bindgen(js_name = renderInto)]
pub fn render_into(
&mut self,
source: &str,
parent_node_id_str: &str,
state_js: JsValue,
) -> Result<(), JsValue> {
let doc = hypen_parser::parse_document(source)
.map_err(|e| structured_error("parseError", &format_parse_errors(&e)))?;
self.import_visited.clear();
self.resolve_imports(&doc.imports);
let component = doc
.components
.first()
.ok_or_else(|| structured_error("parseError", "No component found in source"))?;
let ir_node = ast_to_ir_node(component);
self.resolve_ir_node_components(&ir_node);
let mut expanded = self.core.component_registry.expand_ir_node(&ir_node);
if !self.core.resource_registry.is_empty() {
crate::ir::resolve_icons_in_ir(&self.core.resource_registry, &mut expanded);
}
let state: serde_json::Value = if state_js.is_null() || state_js.is_undefined() {
serde_json::Value::Null
} else {
from_value(state_js).map_err(|e| structured_error("stateError", &e.to_string()))?
};
let parent_id = self
.node_id_index
.get(parent_node_id_str)
.copied()
.ok_or_else(|| {
structured_error(
"renderError",
&format!("Parent node not found: {}", parent_node_id_str),
)
})?;
#[cfg(debug_assertions)]
web_sys::console::log_1(
&format!("[WASM] Rendering into parent node: {}", parent_node_id_str).into(),
);
let mut patches = Vec::new();
let parent_has_children = self
.core.tree
.get(parent_id)
.map(|node| !node.children.is_empty())
.unwrap_or(false);
let ds = if self.core.data_sources.is_empty() {
None
} else {
Some(&self.core.data_sources)
};
let mods = if self.core.modules.is_empty() { None } else { Some(&self.core.modules) };
if parent_has_children {
#[cfg(debug_assertions)]
web_sys::console::log_1(&"[WASM] Reconciling existing route content".into());
if let Some(parent_node) = self.core.tree.get(parent_id) {
if let Some(&first_child_id) = parent_node.children.front() {
let mut ctx = ReconcileCtx {
tree: &mut self.core.tree,
state: &state,
patches: &mut patches,
dependencies: &mut self.core.dependencies,
data_sources: ds,
modules: mods,
};
reconcile_ir_node_impl(&mut ctx, first_child_id, &expanded);
}
}
} else {
#[cfg(debug_assertions)]
web_sys::console::log_1(&"[WASM] Creating new route content".into());
let mut ctx = ReconcileCtx {
tree: &mut self.core.tree,
state: &state,
patches: &mut patches,
dependencies: &mut self.core.dependencies,
data_sources: ds,
modules: mods,
};
create_ir_node_tree_impl(&mut ctx, &expanded, Some(parent_id), false);
}
self.emit_patches(patches);
self.core.revision += 1;
Ok(())
}
fn render(&mut self, ir_node: &IRNode) {
self.resolve_ir_node_components(ir_node);
let patches = self.core.render_ir_node(ir_node);
self.emit_patches(patches);
}
fn resolve_imports(&mut self, imports: &[hypen_parser::ImportStatement]) {
let resolver = match self.component_resolver.clone() {
Some(r) => r,
None => return,
};
for import in imports {
let source_path = import.source_path();
for name in import.imported_names() {
if self
.core.component_registry
.get(&name, Some(source_path))
.is_some()
{
continue;
}
let import_key = format!("{}:{}", source_path, name);
if self.import_visited.contains(&import_key) {
continue;
}
self.import_visited.insert(import_key);
let name_js = JsValue::from_str(&name);
let source_path_js = JsValue::from_str(source_path);
if let Ok(result) = resolver.call2(&JsValue::NULL, &name_js, &source_path_js) {
if result.is_null() || result.is_undefined() {
continue;
}
let source_val =
js_sys::Reflect::get(&result, &JsValue::from_str("source")).ok();
let path_val = js_sys::Reflect::get(&result, &JsValue::from_str("path")).ok();
let passthrough_val =
js_sys::Reflect::get(&result, &JsValue::from_str("passthrough")).ok();
let lazy_val = js_sys::Reflect::get(&result, &JsValue::from_str("lazy")).ok();
if let (Some(source_js), Some(path_js)) = (source_val, path_val) {
if let (Some(resolved_source), Some(path)) =
(source_js.as_string(), path_js.as_string())
{
let is_lazy = lazy_val.and_then(|v| v.as_bool()).unwrap_or(false);
let is_passthrough =
passthrough_val.and_then(|v| v.as_bool()).unwrap_or(false);
if is_lazy {
let dummy_element = Element::new(&name);
let component =
crate::ir::Component::new(name.clone(), move |_props| {
dummy_element.clone()
})
.with_source_path(&path)
.with_lazy(true);
self.core.component_registry.register(component);
} else if is_passthrough {
let dummy_element = Element::new(&name);
let component =
crate::ir::Component::new(name.clone(), move |_props| {
dummy_element.clone()
})
.with_source_path(&path)
.with_passthrough(true);
self.core.component_registry.register(component);
} else {
if let Ok(resolved_doc) =
hypen_parser::parse_document(&resolved_source)
{
self.resolve_imports(&resolved_doc.imports);
if let Some(component_spec) = resolved_doc.components.first() {
let ir_node = ast_to_ir_node(component_spec);
if let IRNode::Element(ir_element) = &ir_node {
let ir_element = ir_element.clone();
let component = crate::ir::Component::new(
name.clone(),
move |_props| ir_element.clone(),
)
.with_source_path(&path);
self.core.component_registry.register(component);
}
self.resolve_ir_node_components_with_context(
&ir_node,
Some(&path),
);
}
} else if let Ok(component_spec) =
hypen_parser::parse_component(&resolved_source)
{
let ir_node = ast_to_ir_node(&component_spec);
if let IRNode::Element(ir_element) = &ir_node {
let ir_element = ir_element.clone();
let component =
crate::ir::Component::new(name.clone(), move |_props| {
ir_element.clone()
})
.with_source_path(&path);
self.core.component_registry.register(component);
}
self.resolve_ir_node_components_with_context(
&ir_node,
Some(&path),
);
}
}
}
}
}
}
}
}
fn resolve_components_with_context(&mut self, element: &Element, context_path: Option<&str>) {
if self.core.component_registry.is_primitive(&element.element_type) {
for child_ir in &element.ir_children {
if let IRNode::Element(child) = child_ir {
self.resolve_components_with_context(child, context_path);
}
}
return;
}
if self
.core.component_registry
.get(&element.element_type, context_path)
.is_none()
{
if let Some(ref resolver) = self.component_resolver.clone() {
let name_js = JsValue::from_str(&element.element_type);
let context_js = context_path.map(JsValue::from_str).unwrap_or(JsValue::NULL);
if let Ok(result) = resolver.call2(&JsValue::NULL, &name_js, &context_js) {
if !result.is_null() && !result.is_undefined() {
let source_val =
js_sys::Reflect::get(&result, &JsValue::from_str("source")).ok();
let path_val =
js_sys::Reflect::get(&result, &JsValue::from_str("path")).ok();
let passthrough_val =
js_sys::Reflect::get(&result, &JsValue::from_str("passthrough")).ok();
let lazy_val =
js_sys::Reflect::get(&result, &JsValue::from_str("lazy")).ok();
if let (Some(source_js), Some(path_js)) = (source_val, path_val) {
if let (Some(source), Some(path)) =
(source_js.as_string(), path_js.as_string())
{
let is_lazy = lazy_val.and_then(|v| v.as_bool()).unwrap_or(false);
let is_passthrough =
passthrough_val.and_then(|v| v.as_bool()).unwrap_or(false);
if is_lazy {
#[cfg(debug_assertions)]
web_sys::console::log_1(
&format!(
"[WASM] Registering lazy component: {}",
element.element_type
)
.into(),
);
let name = element.element_type.clone();
let dummy_element = Element::new(&name);
let component =
crate::ir::Component::new(name.clone(), move |_props| {
dummy_element.clone()
})
.with_source_path(&path)
.with_lazy(true);
self.core.component_registry.register(component);
} else if is_passthrough {
#[cfg(debug_assertions)]
web_sys::console::log_1(
&format!(
"[WASM] Registering passthrough component: {}",
element.element_type
)
.into(),
);
let name = element.element_type.clone();
let dummy_element = Element::new(&name);
let component =
crate::ir::Component::new(name.clone(), move |_props| {
dummy_element.clone()
})
.with_source_path(&path)
.with_passthrough(true);
self.core.component_registry.register(component);
} else {
if let Ok(component_spec) =
hypen_parser::parse_component(&source)
{
let ir_node = ast_to_ir_node(&component_spec);
let name = element.element_type.clone();
let path_clone = path.clone();
if let IRNode::Element(ir_element) = &ir_node {
let ir_element = ir_element.clone();
let spec_is_module = component_spec.declaration_type
== hypen_parser::DeclarationType::Module;
let spec_module_name = if spec_is_module {
Some(component_spec.name.to_lowercase())
} else {
None
};
let mut component = crate::ir::Component::new(
name.clone(),
move |_props| ir_element.clone(),
)
.with_source_path(&path);
if spec_is_module {
component.is_module = true;
component.module_name = spec_module_name;
}
self.core.component_registry.register(component);
}
self.resolve_ir_node_components_with_context(
&ir_node,
Some(&path_clone),
);
}
}
}
}
}
}
}
}
for child_ir in &element.ir_children {
if let IRNode::Element(child) = child_ir {
self.resolve_components_with_context(child, context_path);
}
}
}
fn resolve_ir_node_components(&mut self, node: &IRNode) {
self.resolve_ir_node_components_with_context(node, None);
}
fn resolve_ir_node_components_with_context(
&mut self,
node: &IRNode,
context_path: Option<&str>,
) {
match node {
IRNode::Element(element) => {
self.resolve_components_with_context(element, context_path);
for child in &element.ir_children {
self.resolve_ir_node_components_with_context(child, context_path);
}
}
IRNode::ForEach { template, .. } => {
for child in template {
self.resolve_ir_node_components_with_context(child, context_path);
}
}
IRNode::Conditional {
branches, fallback, ..
} => {
for branch in branches {
for child in &branch.children {
self.resolve_ir_node_components_with_context(child, context_path);
}
}
if let Some(fb) = fallback {
for child in fb {
self.resolve_ir_node_components_with_context(child, context_path);
}
}
}
IRNode::Router {
routes, fallback, ..
} => {
for route in routes {
for child in &route.children {
self.resolve_ir_node_components_with_context(child, context_path);
}
}
if let Some(fb) = fallback {
for child in fb {
self.resolve_ir_node_components_with_context(child, context_path);
}
}
}
}
}
fn emit_patches(&mut self, mut patches: Vec<Patch>) {
EngineCore::filter_spurious_removes(&mut patches);
for patch in &patches {
if let Patch::Create { id, .. } = patch {
if !self.node_id_index.contains_key(id) {
for (node_id, _) in self.core.tree.iter() {
if crate::reconcile::node_id_str(node_id) == *id {
self.node_id_index.insert(id.clone(), node_id);
break;
}
}
}
}
}
if let Some(ref callback) = self.patch_callback {
let serializer = serde_wasm_bindgen::Serializer::json_compatible();
if let Ok(patches_js) = serde::Serialize::serialize(&patches, &serializer) {
let _ = callback.call1(&JsValue::NULL, &patches_js);
}
}
}
#[wasm_bindgen(js_name = updateState)]
pub fn update_state(&mut self, scope: Option<String>, state_patch: JsValue) -> Result<(), JsValue> {
let patch: serde_json::Value = from_value(state_patch)
.map_err(|e| structured_error("stateError", &format!("Invalid state patch: {}", e)))?;
let normalized = scope.as_deref().filter(|s| !s.is_empty());
if self.core.update_state(normalized, patch) {
self.render_dirty();
}
Ok(())
}
#[wasm_bindgen(js_name = updateStateSparse)]
pub fn update_state_sparse(
&mut self,
scope: Option<String>,
paths_js: JsValue,
values_js: JsValue,
) -> Result<(), JsValue> {
let paths: Vec<String> = from_value(paths_js)
.map_err(|e| structured_error("stateError", &format!("Invalid paths array: {}", e)))?;
let values: serde_json::Value = from_value(values_js).map_err(|e| {
structured_error("stateError", &format!("Invalid values object: {}", e))
})?;
let normalized = scope.as_deref().filter(|s| !s.is_empty());
if self.core.update_state_sparse(normalized, &paths, &values) {
self.render_dirty();
}
Ok(())
}
fn render_dirty(&mut self) {
let patches = self.core.render_dirty();
if !patches.is_empty() {
self.emit_patches(patches);
}
}
#[wasm_bindgen(js_name = setContext)]
pub fn set_context(&mut self, name: &str, data_js: JsValue) -> Result<(), JsValue> {
let data: serde_json::Value = from_value(data_js).map_err(|e| {
structured_error(
"stateError",
&format!("Invalid context data: {}", e),
)
})?;
self.core.set_context(name, data);
self.render_dirty();
Ok(())
}
#[wasm_bindgen(js_name = removeContext)]
pub fn remove_context(&mut self, name: &str) {
self.core.remove_context(name);
self.render_dirty();
}
#[wasm_bindgen(js_name = dispatchAction)]
pub fn dispatch_action(&mut self, name: &str, payload: JsValue) -> Result<(), JsValue> {
let payload: Option<serde_json::Value> = if payload.is_undefined() || payload.is_null() {
None
} else {
Some(from_value(payload).map_err(|e| {
structured_error("actionError", &format!("Invalid action payload: {}", e))
})?)
};
let payload = payload.unwrap_or(serde_json::Value::Null);
if let Some(handler) = self.action_handlers.get(name) {
let action = Action::new(name).with_payload(payload);
let serializer = serde_wasm_bindgen::Serializer::json_compatible();
if let Ok(action_js) = serde::Serialize::serialize(&action, &serializer) {
let _ = handler.call1(&JsValue::NULL, &action_js);
}
return Ok(());
}
if let Some(ds_action) = self.core.build_data_source_action(name, payload) {
if let Some(ref handler) = self.data_source_action_handler {
let serializer = serde_wasm_bindgen::Serializer::json_compatible();
if let Ok(action_js) = serde::Serialize::serialize(&ds_action, &serializer) {
let _ = handler.call1(&JsValue::NULL, &action_js);
}
}
}
Ok(())
}
#[wasm_bindgen(js_name = onAction)]
pub fn on_action(&mut self, action_name: &str, handler: js_sys::Function) {
self.action_handlers
.insert(action_name.to_string(), handler);
}
#[wasm_bindgen(js_name = onDataSourceAction)]
pub fn on_data_source_action(&mut self, handler: js_sys::Function) {
self.data_source_action_handler = Some(handler);
}
#[wasm_bindgen(js_name = clearTree)]
pub fn clear_tree(&mut self) {
self.core.tree.clear();
}
#[wasm_bindgen(js_name = debugParseComponent)]
pub fn debug_parse_component(&self, source: &str) -> Result<String, JsValue> {
let component = hypen_parser::parse_component(source)
.map_err(|e| structured_error("parseError", &format_parse_errors(&e)))?;
let debug_info = format!(
"Name: {} | DeclarationType: {:?} | Children: {} | Applicators: {}",
component.name,
component.declaration_type,
component.children.len(),
component.applicators.len(),
);
Ok(debug_info)
}
#[wasm_bindgen(js_name = setModule)]
pub fn set_module(
&mut self,
name: &str,
actions: Vec<String>,
state_keys: Vec<String>,
initial_state: JsValue,
) -> Result<(), JsValue> {
let state: serde_json::Value = from_value(initial_state).map_err(|e| {
structured_error("stateError", &format!("Invalid initial state: {}", e))
})?;
let instance = ModuleInstance::from_config(name, actions, state_keys, state);
self.core.set_module(instance);
Ok(())
}
#[wasm_bindgen(js_name = registerModule)]
pub fn register_module(
&mut self,
name: &str,
actions: Vec<String>,
state_keys: Vec<String>,
initial_state: JsValue,
) -> Result<(), JsValue> {
let state: serde_json::Value = from_value(initial_state).map_err(|e| {
structured_error("stateError", &format!("Invalid initial state: {}", e))
})?;
let instance = ModuleInstance::from_config(name, actions, state_keys, state);
self.core.register_module(name, instance);
Ok(())
}
#[wasm_bindgen(js_name = getRevision)]
pub fn get_revision(&self) -> u64 {
self.core.revision
}
#[wasm_bindgen(js_name = currentState)]
pub fn current_state(&self) -> JsValue {
let state = self
.core.module
.as_ref()
.map(|m| m.get_state())
.unwrap_or(&NULL_STATE);
let serializer = serde_wasm_bindgen::Serializer::json_compatible();
serde::Serialize::serialize(state, &serializer).unwrap_or(JsValue::NULL)
}
#[wasm_bindgen(js_name = treeSize)]
pub fn tree_size(&self) -> usize {
self.core.tree.len()
}
#[wasm_bindgen(js_name = validate)]
pub fn validate(&self) -> JsValue {
if let Some(root_id) = self.core.tree.root() {
if self.core.tree.get(root_id).is_none() {
return JsValue::from_str("Root node ID references a non-existent node");
}
}
for (node_id, node) in self.core.tree.iter() {
for child_id in &node.children {
match self.core.tree.get(*child_id) {
None => {
return JsValue::from_str(&format!(
"Node {} references non-existent child {}",
crate::reconcile::node_id_str(node_id),
crate::reconcile::node_id_str(*child_id),
));
}
Some(child) => {
if child.parent != Some(node_id) {
return JsValue::from_str(&format!(
"Child {} parent back-reference does not point to {}",
crate::reconcile::node_id_str(*child_id),
crate::reconcile::node_id_str(node_id),
));
}
}
}
}
}
JsValue::NULL
}
#[wasm_bindgen(js_name = reset)]
pub fn reset(&mut self) {
self.core = EngineCore::new();
self.action_handlers.clear();
self.import_visited.clear();
self.node_id_index.clear();
}
}
#[wasm_bindgen(js_name = patchesToJson)]
pub fn patches_to_json(patches: JsValue) -> Result<String, JsValue> {
let patches: Vec<Patch> = from_value(patches)
.map_err(|e| structured_error("stateError", &format!("Invalid patches: {}", e)))?;
serde_json::to_string_pretty(&patches)
.map_err(|e| structured_error("renderError", &format!("Serialization error: {}", e)))
}
#[wasm_bindgen(js_name = parseToJson)]
pub fn parse_to_json(source: &str) -> Result<String, JsValue> {
let component = hypen_parser::parse_component(source)
.map_err(|e| structured_error("parseError", &format_parse_errors(&e)))?;
serde_json::to_string_pretty(&component)
.map_err(|e| structured_error("renderError", &format!("Serialization error: {}", e)))
}
#[wasm_bindgen(start)]
pub fn main() {}