Skip to main content

webui_node/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Node.js native addon for the WebUI framework via napi-rs.
5//!
6//! Provides high-performance server-side rendering by compiling the Rust
7//! WebUI handler directly into a `.node` native addon — no C ABI intermediary.
8//!
9//! The `render` function accepts pre-compiled protobuf protocol data (from
10//! `webui build`) and streams rendered HTML fragments via a callback, enabling
11//! true streaming SSR without buffering the entire response.
12//!
13//! ## Usage (from Node.js)
14//!
15//! ```js
16//! import fs from 'node:fs';
17//!
18//! // Load the native addon
19//! const mod = { exports: {} };
20//! process.dlopen(mod, './target/release/libwebui_node.dylib');
21//! const { render } = mod.exports;
22//!
23//! // Read pre-compiled protocol (from `webui build`)
24//! const protocol = fs.readFileSync('./dist/protocol.bin');
25//! const state = '{"name": "WebUI"}';
26//!
27//! // Stream rendered fragments
28//! render(protocol, state, (chunk) => process.stdout.write(chunk));
29//! ```
30
31use napi::bindgen_prelude::{Buffer, Function};
32use napi::Error as NapiError;
33use napi_derive::napi;
34use serde_json::Value;
35use webui_handler::plugin::fast::FastHydrationPlugin;
36use webui_handler::plugin::webui::WebUIHydrationPlugin;
37use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
38use webui_protocol::WebUIProtocol;
39
40/// Build statistics returned from the build function.
41#[napi(object)]
42pub struct JsBuildStats {
43    /// Build duration in milliseconds.
44    pub duration_ms: f64,
45    /// Total number of protocol fragments.
46    pub fragment_count: u32,
47    /// Number of registered components.
48    pub component_count: u32,
49    /// Number of CSS files produced.
50    pub css_file_count: u32,
51    /// Size of the serialized protocol in bytes.
52    pub protocol_size_bytes: u32,
53    /// Number of unique CSS tokens discovered.
54    pub token_count: u32,
55}
56
57/// Result of a successful build operation.
58#[napi(object)]
59pub struct JsBuildResult {
60    /// Serialized protocol (protobuf binary).
61    pub protocol: Buffer,
62    /// CSS files as alternating [filename, content, filename, content, ...].
63    pub css_files: Vec<String>,
64    /// Build statistics.
65    pub stats: JsBuildStats,
66}
67
68/// Build options for the webui build API.
69#[napi(object)]
70pub struct JsBuildOptions {
71    /// Path to the application folder containing templates.
72    pub app_dir: String,
73    /// Entry HTML file name (defaults to "index.html").
74    pub entry: Option<String>,
75    /// CSS mode: "link" (default) or "style".
76    pub css: Option<String>,
77    /// Framework plugin.
78    pub plugin: Option<String>,
79    /// Additional component sources (npm packages or local paths).
80    pub components: Option<Vec<String>>,
81}
82
83/// Build a WebUI application from an app directory.
84///
85/// Returns the compiled protocol bytes, CSS files, and build statistics.
86#[napi]
87#[allow(clippy::cast_possible_truncation)] // stats are bounded by component/file counts
88pub fn build(options: JsBuildOptions) -> napi::Result<JsBuildResult> {
89    let css = options
90        .css
91        .map(|s| s.parse::<webui::CssStrategy>())
92        .transpose()
93        .map_err(NapiError::from_reason)?
94        .unwrap_or_default();
95
96    let plugin = options
97        .plugin
98        .map(|s| s.parse::<webui::Plugin>())
99        .transpose()
100        .map_err(NapiError::from_reason)?;
101
102    let build_options = webui::BuildOptions {
103        app_dir: std::path::PathBuf::from(&options.app_dir),
104        entry: options.entry.unwrap_or_else(|| "index.html".to_string()),
105        css,
106        dom: webui::DomStrategy::Shadow,
107        plugin,
108        components: options.components.unwrap_or_default(),
109    };
110
111    let result = webui::build(build_options)
112        .map_err(|e| NapiError::from_reason(format!("Build error: {e}")))?;
113
114    // Flatten css_files into alternating [filename, content, ...] for JS interop
115    let css_files: Vec<String> = result
116        .css_files
117        .into_iter()
118        .flat_map(|(name, content)| [name, content])
119        .collect();
120
121    Ok(JsBuildResult {
122        protocol: Buffer::from(result.protocol_bytes),
123        css_files,
124        stats: JsBuildStats {
125            duration_ms: result.stats.duration.as_secs_f64() * 1000.0,
126            fragment_count: result.stats.fragment_count as u32,
127            component_count: result.stats.component_count as u32,
128            css_file_count: result.stats.css_file_count as u32,
129            protocol_size_bytes: result.stats.protocol_size_bytes as u32,
130            token_count: result.stats.token_count as u32,
131        },
132    })
133}
134
135/// Inspect protocol bytes and return a JSON representation.
136#[napi]
137pub fn inspect(protocol_data: Buffer) -> napi::Result<String> {
138    webui::inspect_bytes(&protocol_data)
139        .map_err(|e| NapiError::from_reason(format!("Inspect error: {e}")))
140}
141
142/// Produce a complete JSON partial response for client-side navigation.
143///
144/// Combines application state, route templates, inventory, request path, and
145/// matched route chain into a single JSON string:
146/// `{"state":{...},"templates":[...],"inventory":"...","path":"...","chain":[...]}`.
147///
148/// Host servers return this directly — no assembly required.
149#[napi]
150pub fn render_partial(
151    protocol_data: Buffer,
152    state_json: String,
153    entry_id: String,
154    request_path: String,
155    inventory_hex: String,
156) -> napi::Result<String> {
157    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
158        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
159
160    let state: serde_json::Value = serde_json::from_str(&state_json)
161        .map_err(|e| NapiError::from_reason(format!("invalid state JSON: {e}")))?;
162
163    let result = webui_handler::route_handler::render_partial(
164        &protocol,
165        state,
166        &entry_id,
167        &request_path,
168        &inventory_hex,
169    );
170
171    serde_json::to_string(&result)
172        .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
173}
174
175#[napi]
176pub fn render_component_templates(
177    protocol_data: Buffer,
178    component_tags_json: String,
179    inventory_hex: String,
180) -> napi::Result<String> {
181    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
182        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
183
184    let tags: Vec<String> = serde_json::from_str(&component_tags_json)
185        .map_err(|e| NapiError::from_reason(format!("invalid tags JSON: {e}")))?;
186    let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
187
188    let result = webui_handler::route_handler::render_component_templates(
189        &protocol,
190        &tag_refs,
191        &inventory_hex,
192    );
193
194    serde_json::to_string(&result)
195        .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
196}
197
198/// A writer that streams each rendered fragment to a JS callback.
199struct CallbackWriter<'a, 'env> {
200    callback: &'a Function<'env, String, ()>,
201    error: Option<String>,
202}
203
204impl<'a, 'env> CallbackWriter<'a, 'env> {
205    fn new(callback: &'a Function<'env, String, ()>) -> Self {
206        Self {
207            callback,
208            error: None,
209        }
210    }
211}
212
213impl ResponseWriter for CallbackWriter<'_, '_> {
214    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
215        if self.error.is_some() {
216            return Ok(());
217        }
218        if let Err(e) = self.callback.call(content.to_owned()) {
219            // Ignore "Value is not undefined" errors from callbacks that
220            // return non-void (e.g. res.write() returns a boolean).
221            let msg = format!("{e}");
222            if !msg.contains("Value is not undefined") {
223                self.error = Some(msg);
224            }
225        }
226        Ok(())
227    }
228
229    fn end(&mut self) -> webui_handler::Result<()> {
230        Ok(())
231    }
232}
233
234/// Render a pre-compiled WebUI protocol with JSON state, streaming each
235/// fragment to the provided callback.
236///
237/// # Arguments
238///
239/// * `protocol_data` — Protobuf binary from `webui build` (zero-copy Buffer).
240/// * `state_json` — JSON string with the render state.
241/// * `on_chunk` — Called with each rendered HTML fragment as it is produced.
242/// * `plugin` — Optional plugin identifier (e.g., `"fast"`).
243#[napi]
244#[allow(clippy::too_many_arguments)]
245pub fn render(
246    protocol_data: Buffer,
247    state_json: String,
248    entry: String,
249    request_path: String,
250    on_chunk: Function<String, ()>,
251    plugin: Option<String>,
252) -> napi::Result<()> {
253    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
254        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
255
256    let state: Value = serde_json::from_str(&state_json)
257        .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))?;
258
259    let mut writer = CallbackWriter::new(&on_chunk);
260    let plugin = plugin
261        .map(|s| s.parse::<webui::Plugin>())
262        .transpose()
263        .map_err(NapiError::from_reason)?;
264    let handler = match plugin {
265        Some(webui::Plugin::Fast) => {
266            WebUIHandler::with_plugin(|| Box::new(FastHydrationPlugin::new()))
267        }
268        Some(webui::Plugin::WebUI) => {
269            WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
270        }
271        None => WebUIHandler::new(),
272    };
273    handler
274        .render(
275            &protocol,
276            &state,
277            &RenderOptions::new(&entry, &request_path),
278            &mut writer,
279        )
280        .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
281
282    if let Some(err) = writer.error {
283        return Err(NapiError::from_reason(format!("Callback error: {err}")));
284    }
285
286    Ok(())
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use webui_parser::HtmlParser;
293
294    /// Helper: parse HTML into protobuf bytes for testing.
295    fn build_protocol(html: &str) -> Vec<u8> {
296        let mut parser = HtmlParser::new();
297        parser.parse("index.html", html).expect("parse failed");
298        let tokens = parser.take_tokens();
299        let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
300        protocol.to_protobuf().expect("protobuf encode failed")
301    }
302
303    /// Helper: render protocol bytes + state, collecting output into a String.
304    fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
305        let protocol = WebUIProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
306        let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
307
308        let mut output = String::with_capacity(1024);
309        let handler = WebUIHandler::new();
310
311        struct StringWriter<'a> {
312            output: &'a mut String,
313        }
314        impl ResponseWriter for StringWriter<'_> {
315            fn write(&mut self, content: &str) -> webui_handler::Result<()> {
316                self.output.push_str(content);
317                Ok(())
318            }
319            fn end(&mut self) -> webui_handler::Result<()> {
320                Ok(())
321            }
322        }
323
324        let mut writer = StringWriter {
325            output: &mut output,
326        };
327        handler
328            .render(
329                &protocol,
330                &state,
331                &RenderOptions::new("index.html", "/"),
332                &mut writer,
333            )
334            .map_err(|e| e.to_string())?;
335        Ok(output)
336    }
337
338    #[test]
339    fn test_simple_passthrough() {
340        let proto = build_protocol("<p>Hello</p>");
341        let result = render_to_string(&proto, "{}");
342        assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
343    }
344
345    #[test]
346    fn test_signal_substitution() {
347        let proto = build_protocol("Hello, {{name}}!");
348        let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
349        assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
350    }
351
352    #[test]
353    fn test_for_loop() {
354        let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
355        let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
356        assert_eq!(
357            result.as_deref(),
358            Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
359        );
360    }
361
362    #[test]
363    fn test_if_condition_true() {
364        let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
365        let result = render_to_string(&proto, r#"{"show": true}"#);
366        assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
367    }
368
369    #[test]
370    fn test_if_condition_false() {
371        let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
372        let result = render_to_string(&proto, r#"{"show": false}"#);
373        assert_eq!(result.as_deref(), Ok(""));
374    }
375
376    #[test]
377    fn test_html_escaping() {
378        let proto = build_protocol("<div>{{content}}</div>");
379        let state = r#"{"content": "<script>alert('xss')</script>"}"#;
380        let result = render_to_string(&proto, state).expect("render should succeed");
381        assert!(!result.contains("<script>"));
382        assert!(result.contains("&lt;script&gt;"));
383    }
384
385    #[test]
386    fn test_raw_signal() {
387        let proto = build_protocol("<div>{{{content}}}</div>");
388        let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
389        assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
390    }
391
392    #[test]
393    fn test_invalid_json() {
394        let proto = build_protocol("<p>hi</p>");
395        let result = render_to_string(&proto, "NOT JSON");
396        assert!(result.is_err());
397    }
398
399    #[test]
400    fn test_empty_state() {
401        let proto = build_protocol("<p>static</p>");
402        let result = render_to_string(&proto, "{}");
403        assert_eq!(result.as_deref(), Ok("<p>static</p>"));
404    }
405
406    #[test]
407    fn test_nested_object_signal() {
408        let proto = build_protocol("{{user.name}}");
409        let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
410        assert_eq!(result.as_deref(), Ok("Alice"));
411    }
412
413    #[test]
414    fn test_invalid_protobuf() {
415        let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
416        assert!(result.is_err());
417    }
418
419    // ── Tests for build() and inspect() napi exports ─────────────────
420
421    #[test]
422    fn test_build_simple_app() {
423        let dir = tempfile::TempDir::new().unwrap();
424        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
425
426        let options = JsBuildOptions {
427            app_dir: dir.path().to_string_lossy().to_string(),
428            entry: None,
429            css: None,
430            plugin: None,
431            components: None,
432        };
433
434        let result = build(options).unwrap();
435        assert!(!result.protocol.is_empty());
436        assert!(result.stats.fragment_count > 0);
437        assert!(result.stats.protocol_size_bytes > 0);
438        assert!(result.stats.duration_ms >= 0.0);
439    }
440
441    #[test]
442    fn test_build_with_custom_entry() {
443        let dir = tempfile::TempDir::new().unwrap();
444        std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
445
446        let options = JsBuildOptions {
447            app_dir: dir.path().to_string_lossy().to_string(),
448            entry: Some("page.html".to_string()),
449            css: None,
450            plugin: None,
451            components: None,
452        };
453
454        let result = build(options).unwrap();
455        assert!(!result.protocol.is_empty());
456    }
457
458    #[test]
459    fn test_build_missing_app_dir() {
460        let options = JsBuildOptions {
461            app_dir: "/nonexistent/path".to_string(),
462            entry: None,
463            css: None,
464            plugin: None,
465            components: None,
466        };
467
468        let result = build(options);
469        assert!(result.is_err());
470    }
471
472    #[test]
473    fn test_build_invalid_css() {
474        let dir = tempfile::TempDir::new().unwrap();
475        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
476
477        let options = JsBuildOptions {
478            app_dir: dir.path().to_string_lossy().to_string(),
479            entry: None,
480            css: Some("bogus".to_string()),
481            plugin: None,
482            components: None,
483        };
484
485        let result = build(options);
486        assert!(result.is_err());
487    }
488
489    #[test]
490    fn test_build_with_components_css() {
491        let dir = tempfile::TempDir::new().unwrap();
492        std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
493        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
494        std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
495
496        let options = JsBuildOptions {
497            app_dir: dir.path().to_string_lossy().to_string(),
498            entry: None,
499            css: Some("link".to_string()),
500            plugin: None,
501            components: None,
502        };
503
504        let result = build(options).unwrap();
505        // css_files is flattened: [filename, content, filename, content, ...]
506        assert_eq!(result.css_files.len(), 2);
507        assert_eq!(result.css_files[0], "my-card.css");
508        assert!(result.css_files[1].contains("color: red"));
509        assert_eq!(result.stats.css_file_count, 1);
510    }
511
512    #[test]
513    fn test_inspect_valid_protocol() {
514        let proto = build_protocol("<h1>Hello {{name}}</h1>");
515        let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
516        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
517        assert!(parsed.get("fragments").is_some());
518    }
519
520    #[test]
521    fn test_inspect_invalid_protocol() {
522        let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
523        assert!(result.is_err());
524    }
525}