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_v2::FastV2HydrationPlugin;
36use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
37use webui_handler::plugin::webui::WebUIHydrationPlugin;
38use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
39use webui_protocol::WebUIProtocol;
40
41/// Build statistics returned from the build function.
42#[napi(object)]
43pub struct JsBuildStats {
44    /// Build duration in milliseconds.
45    pub duration_ms: f64,
46    /// Total number of protocol fragments.
47    pub fragment_count: u32,
48    /// Number of registered components.
49    pub component_count: u32,
50    /// Number of CSS files produced.
51    pub css_file_count: u32,
52    /// Size of the serialized protocol in bytes.
53    pub protocol_size_bytes: u32,
54    /// Number of unique CSS tokens discovered.
55    pub token_count: u32,
56}
57
58/// Result of a successful build operation.
59#[napi(object)]
60pub struct JsBuildResult {
61    /// Serialized protocol (protobuf binary).
62    pub protocol: Buffer,
63    /// CSS files as alternating [filename, content, filename, content, ...].
64    pub css_files: Vec<String>,
65    /// Build statistics.
66    pub stats: JsBuildStats,
67}
68
69/// Build options for the webui build API.
70#[napi(object)]
71pub struct JsBuildOptions {
72    /// Path to the application folder containing templates.
73    pub app_dir: String,
74    /// Entry HTML file name (defaults to "index.html").
75    pub entry: Option<String>,
76    /// CSS mode: "link" (default) or "style".
77    pub css: Option<String>,
78    /// DOM strategy for component rendering: "shadow" (default) or "light".
79    pub dom: Option<String>,
80    /// Plugin identifier (see crate documentation for available identifiers).
81    pub plugin: Option<String>,
82    /// Additional component sources (npm packages or local paths).
83    pub components: Option<Vec<String>>,
84}
85
86/// Build a WebUI application from an app directory.
87///
88/// Returns the compiled protocol bytes, CSS files, and build statistics.
89#[napi]
90#[allow(clippy::cast_possible_truncation)] // stats are bounded by component/file counts
91pub fn build(options: JsBuildOptions) -> napi::Result<JsBuildResult> {
92    let css = options
93        .css
94        .map(|s| s.parse::<webui::CssStrategy>())
95        .transpose()
96        .map_err(NapiError::from_reason)?
97        .unwrap_or_default();
98
99    let dom = options
100        .dom
101        .map(|s| s.parse::<webui::DomStrategy>())
102        .transpose()
103        .map_err(NapiError::from_reason)?
104        .unwrap_or_default();
105
106    let plugin = options
107        .plugin
108        .map(|s| s.parse::<webui::Plugin>())
109        .transpose()
110        .map_err(NapiError::from_reason)?;
111
112    let build_options = webui::BuildOptions {
113        app_dir: std::path::PathBuf::from(&options.app_dir),
114        entry: options.entry.unwrap_or_else(|| "index.html".to_string()),
115        css,
116        dom,
117        plugin,
118        components: options.components.unwrap_or_default(),
119    };
120
121    let result = webui::build(build_options)
122        .map_err(|e| NapiError::from_reason(format!("Build error: {e}")))?;
123
124    // Flatten css_files into alternating [filename, content, ...] for JS interop
125    let css_files: Vec<String> = result
126        .css_files
127        .into_iter()
128        .flat_map(|(name, content)| [name, content])
129        .collect();
130
131    Ok(JsBuildResult {
132        protocol: Buffer::from(result.protocol_bytes),
133        css_files,
134        stats: JsBuildStats {
135            duration_ms: result.stats.duration.as_secs_f64() * 1000.0,
136            fragment_count: result.stats.fragment_count as u32,
137            component_count: result.stats.component_count as u32,
138            css_file_count: result.stats.css_file_count as u32,
139            protocol_size_bytes: result.stats.protocol_size_bytes as u32,
140            token_count: result.stats.token_count as u32,
141        },
142    })
143}
144
145/// Inspect protocol bytes and return a JSON representation.
146#[napi]
147pub fn inspect(protocol_data: Buffer) -> napi::Result<String> {
148    webui::inspect_bytes(&protocol_data)
149        .map_err(|e| NapiError::from_reason(format!("Inspect error: {e}")))
150}
151
152/// Produce a complete JSON partial response for client-side navigation.
153///
154/// Combines application state, route templates, inventory, request path, and
155/// matched route chain into a single JSON string:
156/// `{"state":{...},"templates":[...],"inventory":"...","path":"...","chain":[...]}`.
157///
158/// Host servers return this directly — no assembly required.
159#[napi]
160pub fn render_partial(
161    protocol_data: Buffer,
162    state_json: String,
163    entry_id: String,
164    request_path: String,
165    inventory_hex: String,
166) -> napi::Result<String> {
167    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
168        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
169
170    let state: serde_json::Value = serde_json::from_str(&state_json)
171        .map_err(|e| NapiError::from_reason(format!("invalid state JSON: {e}")))?;
172
173    // TODO: ProtocolIndex is created per-request here because the Node binding
174    // receives the protocol fresh each call. Ideally the host should cache it
175    // alongside the protocol for the lifetime of the server — it's deterministic
176    // per protocol and avoids rebuilding the component index + route cache.
177    let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
178
179    let mut result = webui_handler::route_handler::render_partial(
180        &protocol,
181        &entry_id,
182        &request_path,
183        &inventory_hex,
184        &mut index,
185    )
186    .map_err(|e| NapiError::from_reason(format!("render_partial failed: {e}")))?;
187    if let Some(obj) = result.as_object_mut() {
188        obj.insert("state".into(), state);
189    }
190
191    serde_json::to_string(&result)
192        .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
193}
194
195#[napi]
196pub fn render_component_templates(
197    protocol_data: Buffer,
198    component_tags_json: String,
199    inventory_hex: String,
200) -> napi::Result<String> {
201    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
202        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
203
204    let tags: Vec<String> = serde_json::from_str(&component_tags_json)
205        .map_err(|e| NapiError::from_reason(format!("invalid tags JSON: {e}")))?;
206    let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
207
208    // Per-request index — see ProtocolIndex doc for caching guidance.
209    let index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
210
211    let result = webui_handler::route_handler::render_component_templates(
212        &protocol,
213        &tag_refs,
214        &inventory_hex,
215        &index,
216    )
217    .map_err(|e| NapiError::from_reason(format!("render_component_templates failed: {e}")))?;
218
219    serde_json::to_string(&result)
220        .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
221}
222
223/// Extract the CSS token name list from a compiled protocol.
224///
225/// Returns the tokens as a JavaScript string array, preserving the original
226/// order from the build step.
227#[napi]
228pub fn protocol_tokens(protocol_data: Buffer) -> napi::Result<Vec<String>> {
229    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
230        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
231
232    Ok(protocol.tokens)
233}
234
235/// A writer that streams each rendered fragment to a JS callback.
236struct CallbackWriter<'a, 'env> {
237    callback: &'a Function<'env, String, ()>,
238    error: Option<String>,
239}
240
241impl<'a, 'env> CallbackWriter<'a, 'env> {
242    fn new(callback: &'a Function<'env, String, ()>) -> Self {
243        Self {
244            callback,
245            error: None,
246        }
247    }
248}
249
250impl ResponseWriter for CallbackWriter<'_, '_> {
251    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
252        if self.error.is_some() {
253            return Ok(());
254        }
255        if let Err(e) = self.callback.call(content.to_owned()) {
256            // Ignore "Value is not undefined" errors from callbacks that
257            // return non-void (e.g. res.write() returns a boolean).
258            let msg = format!("{e}");
259            if !msg.contains("Value is not undefined") {
260                self.error = Some(msg);
261            }
262        }
263        Ok(())
264    }
265
266    fn end(&mut self) -> webui_handler::Result<()> {
267        Ok(())
268    }
269}
270
271/// Render a pre-compiled WebUI protocol with JSON state, streaming each
272/// fragment to the provided callback.
273///
274/// # Arguments
275///
276/// * `protocol_data` — Protobuf binary from `webui build` (zero-copy Buffer).
277/// * `state_json` — JSON string with the render state.
278/// * `on_chunk` — Called with each rendered HTML fragment as it is produced.
279/// * `plugin` — Optional plugin identifier (see crate documentation for available identifiers).
280#[napi]
281#[allow(clippy::too_many_arguments)]
282pub fn render(
283    protocol_data: Buffer,
284    state_json: String,
285    entry: String,
286    request_path: String,
287    on_chunk: Function<String, ()>,
288    plugin: Option<String>,
289) -> napi::Result<()> {
290    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
291        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
292
293    let state: Value = serde_json::from_str(&state_json)
294        .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))?;
295
296    let mut writer = CallbackWriter::new(&on_chunk);
297    let plugin = plugin
298        .map(|s| s.parse::<webui::Plugin>())
299        .transpose()
300        .map_err(NapiError::from_reason)?;
301    let handler = match plugin {
302        Some(webui::Plugin::Fast | webui::Plugin::FastV2) => {
303            WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
304        }
305        Some(webui::Plugin::FastV3) => {
306            WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
307        }
308        Some(webui::Plugin::WebUI) => {
309            WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
310        }
311        None => WebUIHandler::new(),
312    };
313    handler
314        .render(
315            &protocol,
316            &state,
317            &RenderOptions::new(&entry, &request_path),
318            &mut writer,
319        )
320        .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
321
322    if let Some(err) = writer.error {
323        return Err(NapiError::from_reason(format!("Callback error: {err}")));
324    }
325
326    Ok(())
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use webui_parser::HtmlParser;
333
334    /// Helper: parse HTML into protobuf bytes for testing.
335    fn build_protocol(html: &str) -> Vec<u8> {
336        let mut parser = HtmlParser::new();
337        parser.parse("index.html", html).expect("parse failed");
338        let tokens = parser.take_tokens();
339        let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
340        protocol.to_protobuf().expect("protobuf encode failed")
341    }
342
343    /// Helper: render protocol bytes + state, collecting output into a String.
344    fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
345        let protocol = WebUIProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
346        let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
347
348        let mut output = String::with_capacity(1024);
349        let handler = WebUIHandler::new();
350
351        struct StringWriter<'a> {
352            output: &'a mut String,
353        }
354        impl ResponseWriter for StringWriter<'_> {
355            fn write(&mut self, content: &str) -> webui_handler::Result<()> {
356                self.output.push_str(content);
357                Ok(())
358            }
359            fn end(&mut self) -> webui_handler::Result<()> {
360                Ok(())
361            }
362        }
363
364        let mut writer = StringWriter {
365            output: &mut output,
366        };
367        handler
368            .render(
369                &protocol,
370                &state,
371                &RenderOptions::new("index.html", "/"),
372                &mut writer,
373            )
374            .map_err(|e| e.to_string())?;
375        Ok(output)
376    }
377
378    #[test]
379    fn test_simple_passthrough() {
380        let proto = build_protocol("<p>Hello</p>");
381        let result = render_to_string(&proto, "{}");
382        assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
383    }
384
385    #[test]
386    fn test_signal_substitution() {
387        let proto = build_protocol("Hello, {{name}}!");
388        let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
389        assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
390    }
391
392    #[test]
393    fn test_for_loop() {
394        let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
395        let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
396        assert_eq!(
397            result.as_deref(),
398            Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
399        );
400    }
401
402    #[test]
403    fn test_if_condition_true() {
404        let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
405        let result = render_to_string(&proto, r#"{"show": true}"#);
406        assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
407    }
408
409    #[test]
410    fn test_if_condition_false() {
411        let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
412        let result = render_to_string(&proto, r#"{"show": false}"#);
413        assert_eq!(result.as_deref(), Ok(""));
414    }
415
416    #[test]
417    fn test_html_escaping() {
418        let proto = build_protocol("<div>{{content}}</div>");
419        let state = r#"{"content": "<script>alert('xss')</script>"}"#;
420        let result = render_to_string(&proto, state).expect("render should succeed");
421        assert!(!result.contains("<script>"));
422        assert!(result.contains("&lt;script&gt;"));
423    }
424
425    #[test]
426    fn test_raw_signal() {
427        let proto = build_protocol("<div>{{{content}}}</div>");
428        let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
429        assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
430    }
431
432    #[test]
433    fn test_invalid_json() {
434        let proto = build_protocol("<p>hi</p>");
435        let result = render_to_string(&proto, "NOT JSON");
436        assert!(result.is_err());
437    }
438
439    #[test]
440    fn test_empty_state() {
441        let proto = build_protocol("<p>static</p>");
442        let result = render_to_string(&proto, "{}");
443        assert_eq!(result.as_deref(), Ok("<p>static</p>"));
444    }
445
446    #[test]
447    fn test_nested_object_signal() {
448        let proto = build_protocol("{{user.name}}");
449        let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
450        assert_eq!(result.as_deref(), Ok("Alice"));
451    }
452
453    #[test]
454    fn test_invalid_protobuf() {
455        let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
456        assert!(result.is_err());
457    }
458
459    // ── Tests for build() and inspect() napi exports ─────────────────
460
461    #[test]
462    fn test_build_simple_app() {
463        let dir = tempfile::TempDir::new().unwrap();
464        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
465
466        let options = JsBuildOptions {
467            app_dir: dir.path().to_string_lossy().to_string(),
468            entry: None,
469            css: None,
470            dom: None,
471            plugin: None,
472            components: None,
473        };
474
475        let result = build(options).unwrap();
476        assert!(!result.protocol.is_empty());
477        assert!(result.stats.fragment_count > 0);
478        assert!(result.stats.protocol_size_bytes > 0);
479        assert!(result.stats.duration_ms >= 0.0);
480    }
481
482    #[test]
483    fn test_build_with_custom_entry() {
484        let dir = tempfile::TempDir::new().unwrap();
485        std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
486
487        let options = JsBuildOptions {
488            app_dir: dir.path().to_string_lossy().to_string(),
489            entry: Some("page.html".to_string()),
490            css: None,
491            dom: None,
492            plugin: None,
493            components: None,
494        };
495
496        let result = build(options).unwrap();
497        assert!(!result.protocol.is_empty());
498    }
499
500    #[test]
501    fn test_build_missing_app_dir() {
502        let options = JsBuildOptions {
503            app_dir: "/nonexistent/path".to_string(),
504            entry: None,
505            css: None,
506            dom: None,
507            plugin: None,
508            components: None,
509        };
510
511        let result = build(options);
512        assert!(result.is_err());
513    }
514
515    #[test]
516    fn test_build_invalid_css() {
517        let dir = tempfile::TempDir::new().unwrap();
518        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
519
520        let options = JsBuildOptions {
521            app_dir: dir.path().to_string_lossy().to_string(),
522            entry: None,
523            css: Some("bogus".to_string()),
524            dom: None,
525            plugin: None,
526            components: None,
527        };
528
529        let result = build(options);
530        assert!(result.is_err());
531    }
532
533    #[test]
534    fn test_build_with_components_css() {
535        let dir = tempfile::TempDir::new().unwrap();
536        std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
537        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
538        std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
539
540        let options = JsBuildOptions {
541            app_dir: dir.path().to_string_lossy().to_string(),
542            entry: None,
543            css: Some("link".to_string()),
544            dom: None,
545            plugin: None,
546            components: None,
547        };
548
549        let result = build(options).unwrap();
550        // css_files is flattened: [filename, content, filename, content, ...]
551        assert_eq!(result.css_files.len(), 2);
552        assert_eq!(result.css_files[0], "my-card.css");
553        assert!(result.css_files[1].contains("color: red"));
554        assert_eq!(result.stats.css_file_count, 1);
555    }
556
557    #[test]
558    fn test_build_with_light_dom_omits_shadow_root_template() {
559        let dir = tempfile::TempDir::new().unwrap();
560        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
561        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
562
563        let options = JsBuildOptions {
564            app_dir: dir.path().to_string_lossy().to_string(),
565            entry: None,
566            css: None,
567            dom: Some("light".to_string()),
568            plugin: None,
569            components: None,
570        };
571
572        let result = build(options).unwrap();
573        let json = inspect(result.protocol).unwrap();
574        assert!(
575            !json.contains("shadowrootmode"),
576            "light DOM build should not emit shadowrootmode wrappers, got: {json}"
577        );
578    }
579
580    #[test]
581    fn test_build_with_shadow_dom_emits_shadow_root_template() {
582        let dir = tempfile::TempDir::new().unwrap();
583        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
584        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
585
586        let options = JsBuildOptions {
587            app_dir: dir.path().to_string_lossy().to_string(),
588            entry: None,
589            css: None,
590            dom: Some("shadow".to_string()),
591            plugin: None,
592            components: None,
593        };
594
595        let result = build(options).unwrap();
596        let json = inspect(result.protocol).unwrap();
597        assert!(
598            json.contains("shadowrootmode"),
599            "shadow DOM build should emit shadowrootmode wrappers, got: {json}"
600        );
601    }
602
603    #[test]
604    fn test_build_invalid_dom() {
605        let dir = tempfile::TempDir::new().unwrap();
606        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
607
608        let options = JsBuildOptions {
609            app_dir: dir.path().to_string_lossy().to_string(),
610            entry: None,
611            css: None,
612            dom: Some("bogus".to_string()),
613            plugin: None,
614            components: None,
615        };
616
617        let result = build(options);
618        assert!(result.is_err());
619    }
620
621    #[test]
622    fn test_inspect_valid_protocol() {
623        let proto = build_protocol("<h1>Hello {{name}}</h1>");
624        let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
625        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
626        assert!(parsed.get("fragments").is_some());
627    }
628
629    #[test]
630    fn test_inspect_invalid_protocol() {
631        let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
632        assert!(result.is_err());
633    }
634
635    // ── Tests for protocol_tokens napi export ────────────────────────
636
637    #[test]
638    fn test_protocol_tokens_empty() {
639        let proto = build_protocol("<p>Hello</p>");
640        let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
641        assert!(tokens.is_empty());
642    }
643
644    #[test]
645    fn test_protocol_tokens_returns_parsed_tokens() {
646        // Build from a protocol that has CSS tokens via with_tokens constructor.
647        let mut parser = HtmlParser::new();
648        parser.parse("index.html", "<p>Hi</p>").expect("parse");
649        let protocol = WebUIProtocol::with_tokens(
650            parser.into_fragment_records(),
651            vec![
652                "colorBrandBackground".to_string(),
653                "fontSizeBase300".to_string(),
654            ],
655        );
656        let proto = protocol.to_protobuf().expect("encode");
657        let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
658        assert_eq!(tokens, vec!["colorBrandBackground", "fontSizeBase300"]);
659    }
660
661    #[test]
662    fn test_protocol_tokens_invalid_protobuf() {
663        let result = protocol_tokens(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
664        assert!(result.is_err());
665    }
666}