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