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