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