Skip to main content

webui_wasm/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebAssembly bindings for the WebUI framework.
5//!
6//! This crate exposes the WebUI rendering pipeline to JavaScript via `wasm-bindgen`,
7//! powering the interactive playground in the documentation site.
8//!
9//! Two modes of operation:
10//! - **`render`** — Takes a pre-built protocol (JSON) + state and renders HTML.
11//! - **`build_and_render`** — Takes virtual files + state, parses and renders HTML
12//!   using the real `webui-parser` (same parser used by the CLI).
13
14use serde_json::Value;
15use std::collections::HashMap;
16use wasm_bindgen::prelude::*;
17use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
18use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
19use webui_handler::plugin::webui::WebUIHydrationPlugin;
20use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
21use webui_parser::{CssStrategy, HtmlParser, Plugin};
22use webui_protocol::WebUIProtocol;
23
24/// A simple string buffer for collecting rendered output.
25struct StringWriter {
26    content: String,
27}
28
29impl StringWriter {
30    fn with_capacity(cap: usize) -> Self {
31        Self {
32            content: String::with_capacity(cap),
33        }
34    }
35}
36
37impl ResponseWriter for StringWriter {
38    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
39        self.content.push_str(content);
40        Ok(())
41    }
42
43    fn end(&mut self) -> webui_handler::Result<()> {
44        Ok(())
45    }
46}
47
48/// Render a pre-built WebUI protocol with state data.
49///
50/// # Arguments
51///
52/// * `protocol_json` — JSON string of the serialized `WebUIProtocol`.
53/// * `state_json` — JSON string of the state data.
54/// * `plugin` — Optional plugin identifier (see crate documentation for available identifiers).
55///
56/// # Returns
57///
58/// The rendered HTML string, or throws a JS error on failure.
59#[wasm_bindgen]
60pub fn render(
61    protocol_json: &str,
62    state_json: &str,
63    entry: &str,
64    request_path: &str,
65    plugin: Option<String>,
66) -> Result<String, JsValue> {
67    let plugin = plugin
68        .map(|s| s.parse::<Plugin>())
69        .transpose()
70        .map_err(|e| JsValue::from_str(&e))?;
71    render_inner(protocol_json, state_json, entry, request_path, plugin)
72        .map_err(|e| JsValue::from_str(&e.to_string()))
73}
74
75/// Build and render a WebUI application from virtual files.
76///
77/// Uses a lightweight pure-Rust parser suitable for the playground.
78/// Handles signals, for-loops, if-conditions, components, and dynamic attributes.
79///
80/// # Arguments
81///
82/// * `files` — A JS object mapping filenames to their string content.
83///   Example: `{ "index.html": "<h1>{{title}}</h1>", "my-card.html": "<p><slot></slot></p>" }`
84/// * `state_json` — A JSON string of the state data to render with.
85/// * `entry` — The entry HTML filename (e.g. `"index.html"`).
86///
87/// # Returns
88///
89/// The rendered HTML string, or throws a JS error on failure.
90#[wasm_bindgen]
91pub fn build_and_render(
92    files: JsValue,
93    state_json: &str,
94    entry: &str,
95    request_path: &str,
96) -> Result<String, JsValue> {
97    let files_map: HashMap<String, String> =
98        serde_wasm_bindgen::from_value(files).map_err(|e| JsValue::from_str(&e.to_string()))?;
99
100    build_and_render_inner(&files_map, state_json, entry, request_path)
101        .map_err(|e| JsValue::from_str(&e.to_string()))
102}
103
104/// Build the protocol JSON from virtual files without rendering.
105///
106/// Returns the serialized `WebUIProtocol` as a JSON string.
107#[wasm_bindgen]
108pub fn build_protocol(files: JsValue, entry: &str) -> Result<String, JsValue> {
109    let files_map: HashMap<String, String> =
110        serde_wasm_bindgen::from_value(files).map_err(|e| JsValue::from_str(&e.to_string()))?;
111
112    build_protocol_inner(&files_map, entry).map_err(|e| JsValue::from_str(&e.to_string()))
113}
114
115/// Produce a complete JSON partial response for client-side navigation.
116///
117/// Combines application state, route templates, inventory, request path, and
118/// matched route chain into a single JSON string:
119/// `{"state":{...},"templates":[...],"inventory":"...","path":"...","chain":[...]}`.
120///
121/// Host servers return this directly — no assembly required.
122#[wasm_bindgen]
123pub fn render_partial(
124    protocol_json: &str,
125    state_json: &str,
126    entry_id: &str,
127    request_path: &str,
128    inventory_hex: &str,
129) -> Result<String, JsValue> {
130    let protocol: WebUIProtocol = serde_json::from_str(protocol_json)
131        .map_err(|e| JsValue::from_str(&format!("Protocol JSON error: {e}")))?;
132
133    let state: serde_json::Value = serde_json::from_str(state_json)
134        .map_err(|e| JsValue::from_str(&format!("invalid state JSON: {e}")))?;
135
136    // TODO: ProtocolIndex is created per-request here. Ideally the host should
137    // cache it alongside the protocol — it's deterministic per protocol.
138    let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
139
140    let mut result = webui_handler::route_handler::render_partial(
141        &protocol,
142        entry_id,
143        request_path,
144        inventory_hex,
145        &mut index,
146    )
147    .map_err(|e| JsValue::from_str(&format!("render_partial failed: {e}")))?;
148    if let Some(obj) = result.as_object_mut() {
149        obj.insert("state".into(), state);
150    }
151
152    serde_json::to_string(&result)
153        .map_err(|e| JsValue::from_str(&format!("JSON serialize error: {e}")))
154}
155
156/// Extract the CSS token name list from a protocol JSON string.
157///
158/// Returns a JavaScript array of token name strings, preserving the original
159/// order from the build step.
160#[wasm_bindgen]
161pub fn protocol_tokens(protocol_json: &str) -> Result<JsValue, JsValue> {
162    let protocol: WebUIProtocol = serde_json::from_str(protocol_json)
163        .map_err(|e| JsValue::from_str(&format!("Protocol JSON error: {e}")))?;
164
165    serde_wasm_bindgen::to_value(&protocol.tokens)
166        .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
167}
168
169#[wasm_bindgen]
170pub fn render_component_templates(
171    protocol_json: &str,
172    component_tags_json: &str,
173    inventory_hex: &str,
174) -> Result<String, JsValue> {
175    let protocol: WebUIProtocol = serde_json::from_str(protocol_json)
176        .map_err(|e| JsValue::from_str(&format!("Protocol JSON error: {e}")))?;
177
178    let tags: Vec<String> = serde_json::from_str(component_tags_json)
179        .map_err(|e| JsValue::from_str(&format!("invalid tags JSON: {e}")))?;
180    let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
181
182    // Per-request index — see ProtocolIndex doc for caching guidance.
183    let index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
184
185    let result = webui_handler::route_handler::render_component_templates(
186        &protocol,
187        &tag_refs,
188        inventory_hex,
189        &index,
190    )
191    .map_err(|e| JsValue::from_str(&format!("render_component_templates failed: {e}")))?;
192
193    serde_json::to_string(&result)
194        .map_err(|e| JsValue::from_str(&format!("JSON serialize error: {e}")))
195}
196
197fn build_protocol_inner(
198    files: &HashMap<String, String>,
199    entry: &str,
200) -> Result<String, BuildError> {
201    let protocol = parse_to_protocol(files, entry)?;
202    serde_json::to_string(&protocol).map_err(BuildError::Protocol)
203}
204
205/// Create a handler with an optional plugin.
206fn create_handler(plugin: Option<Plugin>) -> Result<WebUIHandler, BuildError> {
207    match plugin {
208        Some(Plugin::Fast | Plugin::FastV2) => Ok(WebUIHandler::with_plugin(|| {
209            Box::new(FastV2HydrationPlugin::new())
210        })),
211        Some(Plugin::FastV3) => Ok(WebUIHandler::with_plugin(|| {
212            Box::new(FastV3HydrationPlugin::new())
213        })),
214        Some(Plugin::WebUI) => Ok(WebUIHandler::with_plugin(|| {
215            Box::new(WebUIHydrationPlugin::new())
216        })),
217        None => Ok(WebUIHandler::new()),
218    }
219}
220
221fn render_inner(
222    protocol_json: &str,
223    state_json: &str,
224    entry: &str,
225    request_path: &str,
226    plugin: Option<Plugin>,
227) -> Result<String, BuildError> {
228    let protocol: WebUIProtocol =
229        serde_json::from_str(protocol_json).map_err(BuildError::Protocol)?;
230    let state: Value = serde_json::from_str(state_json).map_err(BuildError::State)?;
231
232    let mut writer = StringWriter::with_capacity(1024);
233    let handler = create_handler(plugin)?;
234    handler.render(
235        &protocol,
236        &state,
237        &RenderOptions::new(entry, request_path),
238        &mut writer,
239    )?;
240
241    Ok(writer.content)
242}
243
244/// Core build-and-render implementation (testable without WASM).
245pub(crate) fn build_and_render_inner(
246    files: &HashMap<String, String>,
247    state_json: &str,
248    entry: &str,
249    request_path: &str,
250) -> Result<String, BuildError> {
251    let protocol = parse_to_protocol(files, entry)?;
252
253    let state: Value = serde_json::from_str(state_json).map_err(BuildError::State)?;
254
255    let mut writer = StringWriter::with_capacity(1024);
256    let handler = create_handler(None)?;
257    handler.render(
258        &protocol,
259        &state,
260        &RenderOptions::new(entry, request_path),
261        &mut writer,
262    )?;
263
264    Ok(writer.content)
265}
266
267/// Parse virtual files into a `WebUIProtocol` using the real `webui-parser`.
268fn parse_to_protocol(
269    files: &HashMap<String, String>,
270    entry: &str,
271) -> Result<WebUIProtocol, BuildError> {
272    let entry_html = files
273        .get(entry)
274        .ok_or_else(|| BuildError::MissingEntry(entry.to_string()))?;
275
276    let mut parser = HtmlParser::new();
277    parser.set_css_strategy(CssStrategy::Style);
278
279    // Register components from virtual files (no filesystem needed)
280    for (filename, content) in files {
281        if filename != entry && filename.ends_with(".html") {
282            let tag_name = filename.trim_end_matches(".html");
283            if tag_name.contains('-') {
284                let css_key = format!("{tag_name}.css");
285                let css = files.get(&css_key).map(|s| s.as_str());
286                parser
287                    .component_registry_mut()
288                    .register_component(tag_name, content, css)?;
289            }
290        }
291    }
292
293    parser.parse(entry, entry_html)?;
294
295    Ok(WebUIProtocol::new(parser.into_fragment_records()))
296}
297
298/// Errors from the build-and-render pipeline.
299#[derive(Debug, thiserror::Error)]
300pub(crate) enum BuildError {
301    #[error("Entry file '{0}' not found")]
302    MissingEntry(String),
303
304    #[error("{0}")]
305    Parse(#[from] webui_parser::ParserError),
306
307    #[error("Protocol JSON error: {0}")]
308    Protocol(serde_json::Error),
309
310    #[error("State JSON error: {0}")]
311    State(serde_json::Error),
312
313    #[error("{0}")]
314    Render(#[from] webui_handler::HandlerError),
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_simple_render() {
323        let mut files = HashMap::new();
324        files.insert(
325            "index.html".to_string(),
326            "<h1>Hello, {{name}}!</h1>".to_string(),
327        );
328
329        let result = build_and_render_inner(&files, r#"{"name": "WebUI"}"#, "index.html", "/");
330        assert_eq!(result.unwrap(), "<h1>Hello, WebUI!</h1>");
331    }
332
333    #[test]
334    fn test_missing_entry_file() {
335        let files = HashMap::new();
336        let result = build_and_render_inner(&files, "{}", "index.html", "/");
337        assert!(result.is_err());
338        let err = result.unwrap_err().to_string();
339        assert!(err.contains("not found"), "Unexpected error: {}", err);
340    }
341
342    #[test]
343    fn test_with_component() {
344        let mut files = HashMap::new();
345        files.insert(
346            "index.html".to_string(),
347            "<my-card>World</my-card>".to_string(),
348        );
349        files.insert(
350            "my-card.html".to_string(),
351            "<div class=\"card\"><slot></slot></div>".to_string(),
352        );
353
354        let result = build_and_render_inner(&files, "{}", "index.html", "/");
355        assert!(result.is_ok(), "Render failed: {:?}", result);
356        let html = result.as_deref().unwrap_or("");
357        assert!(html.contains("card"), "Expected card class in: {}", html);
358    }
359
360    #[test]
361    fn test_with_for_loop() {
362        let mut files = HashMap::new();
363        files.insert(
364            "index.html".to_string(),
365            "<for each=\"item in items\">{{item.name}}, </for>".to_string(),
366        );
367
368        let state = r#"{"items": [{"name": "A"}, {"name": "B"}]}"#;
369        let result = build_and_render_inner(&files, state, "index.html", "/");
370        assert!(result.is_ok(), "Render failed: {:?}", result);
371        let html = result.as_deref().unwrap_or("");
372        assert!(html.contains("A"), "Expected 'A' in: {}", html);
373        assert!(html.contains("B"), "Expected 'B' in: {}", html);
374    }
375
376    #[test]
377    fn test_with_if_condition() {
378        let mut files = HashMap::new();
379        files.insert(
380            "index.html".to_string(),
381            "<if condition=\"show\">Visible</if>".to_string(),
382        );
383
384        let result_true = build_and_render_inner(&files, r#"{"show": true}"#, "index.html", "/");
385        assert_eq!(result_true.unwrap(), "Visible");
386
387        let result_false = build_and_render_inner(&files, r#"{"show": false}"#, "index.html", "/");
388        assert_eq!(result_false.unwrap(), "");
389    }
390
391    #[test]
392    fn test_invalid_state_json() {
393        let mut files = HashMap::new();
394        files.insert("index.html".to_string(), "<p>Hi</p>".to_string());
395
396        let result = build_and_render_inner(&files, "not json", "index.html", "/");
397        assert!(result.is_err());
398        let err = result.unwrap_err().to_string();
399        assert!(
400            err.contains("State JSON error"),
401            "Unexpected error: {}",
402            err
403        );
404    }
405
406    #[test]
407    fn test_component_with_css() {
408        let mut files = HashMap::new();
409        files.insert(
410            "index.html".to_string(),
411            "<my-card>Content</my-card>".to_string(),
412        );
413        files.insert(
414            "my-card.html".to_string(),
415            "<p><slot></slot></p>".to_string(),
416        );
417        files.insert("my-card.css".to_string(), "p { color: red; }".to_string());
418
419        let result = build_and_render_inner(&files, "{}", "index.html", "/");
420        assert!(result.is_ok(), "Render failed: {:?}", result);
421        let html = result.as_deref().unwrap_or("");
422        // WASM uses CssStrategy::Style, so CSS should be in <style> tags, not <link>
423        assert!(
424            html.contains("<style>p { color: red; }</style>"),
425            "Expected inline <style> tag in: {}",
426            html
427        );
428        assert!(
429            !html.contains("<link"),
430            "Should not have external <link> tag in: {}",
431            html
432        );
433    }
434
435    #[test]
436    fn test_raw_signal() {
437        let mut files = HashMap::new();
438        files.insert(
439            "index.html".to_string(),
440            "<div>{{{raw_html}}}</div>".to_string(),
441        );
442
443        let result =
444            build_and_render_inner(&files, r#"{"raw_html": "<b>bold</b>"}"#, "index.html", "/");
445        assert!(result.is_ok(), "Render failed: {:?}", result);
446        let html = result.as_deref().unwrap_or("");
447        assert!(
448            html.contains("<b>bold</b>"),
449            "Expected raw HTML in: {}",
450            html
451        );
452    }
453
454    #[test]
455    fn test_static_html_passthrough() {
456        let mut files = HashMap::new();
457        files.insert(
458            "index.html".to_string(),
459            "<h1>Static</h1><p>Content</p>".to_string(),
460        );
461
462        let result = build_and_render_inner(&files, "{}", "index.html", "/");
463        assert_eq!(result.unwrap(), "<h1>Static</h1><p>Content</p>");
464    }
465
466    #[test]
467    fn test_protocol_tokens_empty() {
468        let protocol = WebUIProtocol::new(HashMap::new());
469        let json = serde_json::to_string(&protocol).unwrap();
470        let decoded: WebUIProtocol = serde_json::from_str(&json).unwrap();
471        assert!(decoded.tokens.is_empty());
472    }
473
474    #[test]
475    fn test_protocol_tokens_roundtrip() {
476        let tokens = vec![
477            "colorBrandBackground".to_string(),
478            "fontSizeBase300".to_string(),
479        ];
480        let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
481        let json = serde_json::to_string(&protocol).unwrap();
482        let decoded: WebUIProtocol = serde_json::from_str(&json).unwrap();
483        assert_eq!(decoded.tokens, tokens);
484    }
485
486    #[test]
487    fn test_protocol_tokens_preserves_order() {
488        let tokens = vec!["zeta".to_string(), "alpha".to_string(), "zeta".to_string()];
489        let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
490        let json = serde_json::to_string(&protocol).unwrap();
491        let decoded: WebUIProtocol = serde_json::from_str(&json).unwrap();
492        assert_eq!(decoded.tokens, tokens);
493    }
494}