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    // TODO: ProtocolIndex is created per-request here because the Node binding
164    // receives the protocol fresh each call. Ideally the host should cache it
165    // alongside the protocol for the lifetime of the server — it's deterministic
166    // per protocol and avoids rebuilding the component index + route cache.
167    let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
168
169    let mut result = webui_handler::route_handler::render_partial(
170        &protocol,
171        &entry_id,
172        &request_path,
173        &inventory_hex,
174        &mut index,
175    )
176    .map_err(|e| NapiError::from_reason(format!("render_partial failed: {e}")))?;
177    if let Some(obj) = result.as_object_mut() {
178        obj.insert("state".into(), state);
179    }
180
181    serde_json::to_string(&result)
182        .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
183}
184
185#[napi]
186pub fn render_component_templates(
187    protocol_data: Buffer,
188    component_tags_json: String,
189    inventory_hex: String,
190) -> napi::Result<String> {
191    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
192        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
193
194    let tags: Vec<String> = serde_json::from_str(&component_tags_json)
195        .map_err(|e| NapiError::from_reason(format!("invalid tags JSON: {e}")))?;
196    let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
197
198    // Per-request index — see ProtocolIndex doc for caching guidance.
199    let index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
200
201    let result = webui_handler::route_handler::render_component_templates(
202        &protocol,
203        &tag_refs,
204        &inventory_hex,
205        &index,
206    )
207    .map_err(|e| NapiError::from_reason(format!("render_component_templates failed: {e}")))?;
208
209    serde_json::to_string(&result)
210        .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
211}
212
213/// Extract the CSS token name list from a compiled protocol.
214///
215/// Returns the tokens as a JavaScript string array, preserving the original
216/// order from the build step.
217#[napi]
218pub fn protocol_tokens(protocol_data: Buffer) -> napi::Result<Vec<String>> {
219    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
220        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
221
222    Ok(protocol.tokens)
223}
224
225/// A writer that streams each rendered fragment to a JS callback.
226struct CallbackWriter<'a, 'env> {
227    callback: &'a Function<'env, String, ()>,
228    error: Option<String>,
229}
230
231impl<'a, 'env> CallbackWriter<'a, 'env> {
232    fn new(callback: &'a Function<'env, String, ()>) -> Self {
233        Self {
234            callback,
235            error: None,
236        }
237    }
238}
239
240impl ResponseWriter for CallbackWriter<'_, '_> {
241    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
242        if self.error.is_some() {
243            return Ok(());
244        }
245        if let Err(e) = self.callback.call(content.to_owned()) {
246            // Ignore "Value is not undefined" errors from callbacks that
247            // return non-void (e.g. res.write() returns a boolean).
248            let msg = format!("{e}");
249            if !msg.contains("Value is not undefined") {
250                self.error = Some(msg);
251            }
252        }
253        Ok(())
254    }
255
256    fn end(&mut self) -> webui_handler::Result<()> {
257        Ok(())
258    }
259}
260
261/// Render a pre-compiled WebUI protocol with JSON state, streaming each
262/// fragment to the provided callback.
263///
264/// # Arguments
265///
266/// * `protocol_data` — Protobuf binary from `webui build` (zero-copy Buffer).
267/// * `state_json` — JSON string with the render state.
268/// * `on_chunk` — Called with each rendered HTML fragment as it is produced.
269/// * `plugin` — Optional plugin identifier (e.g., `"fast"`).
270#[napi]
271#[allow(clippy::too_many_arguments)]
272pub fn render(
273    protocol_data: Buffer,
274    state_json: String,
275    entry: String,
276    request_path: String,
277    on_chunk: Function<String, ()>,
278    plugin: Option<String>,
279) -> napi::Result<()> {
280    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
281        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
282
283    let state: Value = serde_json::from_str(&state_json)
284        .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))?;
285
286    let mut writer = CallbackWriter::new(&on_chunk);
287    let plugin = plugin
288        .map(|s| s.parse::<webui::Plugin>())
289        .transpose()
290        .map_err(NapiError::from_reason)?;
291    let handler = match plugin {
292        Some(webui::Plugin::Fast) => {
293            WebUIHandler::with_plugin(|| Box::new(FastHydrationPlugin::new()))
294        }
295        Some(webui::Plugin::WebUI) => {
296            WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
297        }
298        None => WebUIHandler::new(),
299    };
300    handler
301        .render(
302            &protocol,
303            &state,
304            &RenderOptions::new(&entry, &request_path),
305            &mut writer,
306        )
307        .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
308
309    if let Some(err) = writer.error {
310        return Err(NapiError::from_reason(format!("Callback error: {err}")));
311    }
312
313    Ok(())
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use webui_parser::HtmlParser;
320
321    /// Helper: parse HTML into protobuf bytes for testing.
322    fn build_protocol(html: &str) -> Vec<u8> {
323        let mut parser = HtmlParser::new();
324        parser.parse("index.html", html).expect("parse failed");
325        let tokens = parser.take_tokens();
326        let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
327        protocol.to_protobuf().expect("protobuf encode failed")
328    }
329
330    /// Helper: render protocol bytes + state, collecting output into a String.
331    fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
332        let protocol = WebUIProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
333        let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
334
335        let mut output = String::with_capacity(1024);
336        let handler = WebUIHandler::new();
337
338        struct StringWriter<'a> {
339            output: &'a mut String,
340        }
341        impl ResponseWriter for StringWriter<'_> {
342            fn write(&mut self, content: &str) -> webui_handler::Result<()> {
343                self.output.push_str(content);
344                Ok(())
345            }
346            fn end(&mut self) -> webui_handler::Result<()> {
347                Ok(())
348            }
349        }
350
351        let mut writer = StringWriter {
352            output: &mut output,
353        };
354        handler
355            .render(
356                &protocol,
357                &state,
358                &RenderOptions::new("index.html", "/"),
359                &mut writer,
360            )
361            .map_err(|e| e.to_string())?;
362        Ok(output)
363    }
364
365    #[test]
366    fn test_simple_passthrough() {
367        let proto = build_protocol("<p>Hello</p>");
368        let result = render_to_string(&proto, "{}");
369        assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
370    }
371
372    #[test]
373    fn test_signal_substitution() {
374        let proto = build_protocol("Hello, {{name}}!");
375        let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
376        assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
377    }
378
379    #[test]
380    fn test_for_loop() {
381        let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
382        let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
383        assert_eq!(
384            result.as_deref(),
385            Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
386        );
387    }
388
389    #[test]
390    fn test_if_condition_true() {
391        let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
392        let result = render_to_string(&proto, r#"{"show": true}"#);
393        assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
394    }
395
396    #[test]
397    fn test_if_condition_false() {
398        let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
399        let result = render_to_string(&proto, r#"{"show": false}"#);
400        assert_eq!(result.as_deref(), Ok(""));
401    }
402
403    #[test]
404    fn test_html_escaping() {
405        let proto = build_protocol("<div>{{content}}</div>");
406        let state = r#"{"content": "<script>alert('xss')</script>"}"#;
407        let result = render_to_string(&proto, state).expect("render should succeed");
408        assert!(!result.contains("<script>"));
409        assert!(result.contains("&lt;script&gt;"));
410    }
411
412    #[test]
413    fn test_raw_signal() {
414        let proto = build_protocol("<div>{{{content}}}</div>");
415        let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
416        assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
417    }
418
419    #[test]
420    fn test_invalid_json() {
421        let proto = build_protocol("<p>hi</p>");
422        let result = render_to_string(&proto, "NOT JSON");
423        assert!(result.is_err());
424    }
425
426    #[test]
427    fn test_empty_state() {
428        let proto = build_protocol("<p>static</p>");
429        let result = render_to_string(&proto, "{}");
430        assert_eq!(result.as_deref(), Ok("<p>static</p>"));
431    }
432
433    #[test]
434    fn test_nested_object_signal() {
435        let proto = build_protocol("{{user.name}}");
436        let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
437        assert_eq!(result.as_deref(), Ok("Alice"));
438    }
439
440    #[test]
441    fn test_invalid_protobuf() {
442        let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
443        assert!(result.is_err());
444    }
445
446    // ── Tests for build() and inspect() napi exports ─────────────────
447
448    #[test]
449    fn test_build_simple_app() {
450        let dir = tempfile::TempDir::new().unwrap();
451        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
452
453        let options = JsBuildOptions {
454            app_dir: dir.path().to_string_lossy().to_string(),
455            entry: None,
456            css: None,
457            plugin: None,
458            components: None,
459        };
460
461        let result = build(options).unwrap();
462        assert!(!result.protocol.is_empty());
463        assert!(result.stats.fragment_count > 0);
464        assert!(result.stats.protocol_size_bytes > 0);
465        assert!(result.stats.duration_ms >= 0.0);
466    }
467
468    #[test]
469    fn test_build_with_custom_entry() {
470        let dir = tempfile::TempDir::new().unwrap();
471        std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
472
473        let options = JsBuildOptions {
474            app_dir: dir.path().to_string_lossy().to_string(),
475            entry: Some("page.html".to_string()),
476            css: None,
477            plugin: None,
478            components: None,
479        };
480
481        let result = build(options).unwrap();
482        assert!(!result.protocol.is_empty());
483    }
484
485    #[test]
486    fn test_build_missing_app_dir() {
487        let options = JsBuildOptions {
488            app_dir: "/nonexistent/path".to_string(),
489            entry: None,
490            css: None,
491            plugin: None,
492            components: None,
493        };
494
495        let result = build(options);
496        assert!(result.is_err());
497    }
498
499    #[test]
500    fn test_build_invalid_css() {
501        let dir = tempfile::TempDir::new().unwrap();
502        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
503
504        let options = JsBuildOptions {
505            app_dir: dir.path().to_string_lossy().to_string(),
506            entry: None,
507            css: Some("bogus".to_string()),
508            plugin: None,
509            components: None,
510        };
511
512        let result = build(options);
513        assert!(result.is_err());
514    }
515
516    #[test]
517    fn test_build_with_components_css() {
518        let dir = tempfile::TempDir::new().unwrap();
519        std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
520        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
521        std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
522
523        let options = JsBuildOptions {
524            app_dir: dir.path().to_string_lossy().to_string(),
525            entry: None,
526            css: Some("link".to_string()),
527            plugin: None,
528            components: None,
529        };
530
531        let result = build(options).unwrap();
532        // css_files is flattened: [filename, content, filename, content, ...]
533        assert_eq!(result.css_files.len(), 2);
534        assert_eq!(result.css_files[0], "my-card.css");
535        assert!(result.css_files[1].contains("color: red"));
536        assert_eq!(result.stats.css_file_count, 1);
537    }
538
539    #[test]
540    fn test_inspect_valid_protocol() {
541        let proto = build_protocol("<h1>Hello {{name}}</h1>");
542        let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
543        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
544        assert!(parsed.get("fragments").is_some());
545    }
546
547    #[test]
548    fn test_inspect_invalid_protocol() {
549        let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
550        assert!(result.is_err());
551    }
552
553    // ── Tests for protocol_tokens napi export ────────────────────────
554
555    #[test]
556    fn test_protocol_tokens_empty() {
557        let proto = build_protocol("<p>Hello</p>");
558        let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
559        assert!(tokens.is_empty());
560    }
561
562    #[test]
563    fn test_protocol_tokens_returns_parsed_tokens() {
564        // Build from a protocol that has CSS tokens via with_tokens constructor.
565        let mut parser = HtmlParser::new();
566        parser.parse("index.html", "<p>Hi</p>").expect("parse");
567        let protocol = WebUIProtocol::with_tokens(
568            parser.into_fragment_records(),
569            vec![
570                "colorBrandBackground".to_string(),
571                "fontSizeBase300".to_string(),
572            ],
573        );
574        let proto = protocol.to_protobuf().expect("encode");
575        let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
576        assert_eq!(tokens, vec!["colorBrandBackground", "fontSizeBase300"]);
577    }
578
579    #[test]
580    fn test_protocol_tokens_invalid_protobuf() {
581        let result = protocol_tokens(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
582        assert!(result.is_err());
583    }
584}