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 `Protocol` class decodes pre-compiled protobuf data from `webui build`
10//! once and provides buffered and streaming render methods.
11//!
12//! ## Usage (from Node.js)
13//!
14//! ```js
15//! import fs from 'node:fs';
16//!
17//! // Load the native addon
18//! const mod = { exports: {} };
19//! process.dlopen(mod, './target/release/libwebui_node.dylib');
20//! const { Protocol } = mod.exports;
21//!
22//! // Read pre-compiled protocol (from `webui build`)
23//! const protocol = new Protocol(fs.readFileSync('./dist/protocol.bin'), 'webui');
24//! const state = '{"name": "WebUI"}';
25//!
26//! // Stream rendered fragments
27//! protocol.renderStream(state, 'index.html', '/', (chunk) => process.stdout.write(chunk));
28//! ```
29
30use napi::bindgen_prelude::{Buffer, Function};
31use napi::Error as NapiError;
32use napi_derive::napi;
33use serde_json::Value;
34use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
35use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
36use webui_handler::plugin::webui::WebUIHydrationPlugin;
37use webui_handler::{Protocol as HandlerProtocol, RenderOptions, ResponseWriter, WebUIHandler};
38#[cfg(test)]
39use webui_protocol::WebUIProtocol;
40
41const STREAM_CHUNK_SIZE: usize = 16 * 1024;
42
43/// Build statistics returned from the build function.
44#[napi(object)]
45pub struct JsBuildStats {
46    /// Build duration in milliseconds.
47    pub duration_ms: f64,
48    /// Total number of protocol fragments.
49    pub fragment_count: u32,
50    /// Number of registered components.
51    pub component_count: u32,
52    /// Number of CSS files produced.
53    pub css_file_count: u32,
54    /// Size of the serialized protocol in bytes.
55    pub protocol_size_bytes: u32,
56    /// Number of unique CSS tokens discovered.
57    pub token_count: u32,
58}
59
60/// Result of a successful build operation.
61#[napi(object)]
62pub struct JsBuildResult {
63    /// Serialized protocol (protobuf binary).
64    pub protocol: Buffer,
65    /// CSS files as alternating [filename, content, filename, content, ...].
66    pub css_files: Vec<String>,
67    /// Static component asset files as alternating [filename, content, filename, content, ...].
68    pub component_asset_files: Vec<String>,
69    /// Non-fatal build advisories (plain text), e.g. CSS tokens used only with a
70    /// literal `var()` fallback and absent from every theme.
71    pub warnings: Vec<String>,
72    /// Build statistics.
73    pub stats: JsBuildStats,
74}
75
76/// Inline projection manifest transported through N-API.
77#[napi(object)]
78pub struct JsProjectionManifest {
79    /// Logical manifest path used to resolve `root` and stale file checks.
80    pub path: String,
81    /// Canonical manifest JSON.
82    pub json: String,
83}
84
85/// Build options for the webui build API.
86#[napi(object)]
87pub struct JsBuildOptions {
88    /// Path to the application folder containing templates.
89    pub app_dir: String,
90    /// Entry HTML file name (defaults to "index.html").
91    pub entry: Option<String>,
92    /// CSS mode: "link" (default) or "style".
93    pub css: Option<String>,
94    /// DOM strategy for component rendering: "shadow" (default) or "light".
95    pub dom: Option<String>,
96    /// Plugin identifier (see crate documentation for available identifiers).
97    pub plugin: Option<String>,
98    /// Additional component sources (npm packages or local paths).
99    pub components: Option<Vec<String>>,
100    /// Root component tags emitted as static `.webui.js` ESM assets.
101    pub component_asset_roots: Option<Vec<String>>,
102    /// Link-mode CSS filename template using [name], [hash], [ext].
103    pub css_file_name_template: Option<String>,
104    /// Optional base URL/path prefix for Link-mode css hrefs.
105    pub css_public_base: Option<String>,
106    /// Legal comment handling: "inline" (default) or "none".
107    pub legal_comments: Option<String>,
108    /// Design token theme: a JSON file path or npm package name.
109    pub theme: Option<String>,
110    /// Projection manifest paths.
111    pub projection_manifests: Option<Vec<String>>,
112    /// Inline manifest objects with their logical paths.
113    pub projection_manifest_objects: Option<Vec<JsProjectionManifest>>,
114}
115
116/// Build a WebUI application from an app directory.
117///
118/// Returns the compiled protocol bytes, CSS files, and build statistics.
119#[napi]
120#[allow(clippy::cast_possible_truncation)] // stats are bounded by component/file counts
121pub fn build(options: JsBuildOptions) -> napi::Result<JsBuildResult> {
122    let css = options
123        .css
124        .map(|s| s.parse::<webui::CssStrategy>())
125        .transpose()
126        .map_err(NapiError::from_reason)?
127        .unwrap_or_default();
128
129    let dom = options
130        .dom
131        .map(|s| s.parse::<webui::DomStrategy>())
132        .transpose()
133        .map_err(NapiError::from_reason)?
134        .unwrap_or_default();
135
136    let plugin = options
137        .plugin
138        .map(|s| s.parse::<webui::Plugin>())
139        .transpose()
140        .map_err(NapiError::from_reason)?;
141
142    let legal_comments = options
143        .legal_comments
144        .map(|s| s.parse::<webui::LegalComments>())
145        .transpose()
146        .map_err(NapiError::from_reason)?
147        .unwrap_or_default();
148
149    let app_dir = std::path::PathBuf::from(&options.app_dir);
150    let theme = options
151        .theme
152        .as_deref()
153        .map(|theme| load_theme(theme, &app_dir))
154        .transpose()?;
155    let mut projection_manifests: Vec<webui::ProjectionManifestSource> = options
156        .projection_manifests
157        .unwrap_or_default()
158        .into_iter()
159        .map(std::path::PathBuf::from)
160        .map(Into::into)
161        .collect();
162    projection_manifests.extend(
163        options
164            .projection_manifest_objects
165            .unwrap_or_default()
166            .into_iter()
167            .map(|manifest| webui::ProjectionManifestSource::Inline {
168                manifest_path: std::path::PathBuf::from(manifest.path),
169                json: manifest.json,
170            }),
171    );
172
173    let build_options = webui::BuildOptions {
174        app_dir,
175        entry: options.entry.unwrap_or_else(|| "index.html".to_string()),
176        css,
177        dom,
178        plugin,
179        components: options.components.unwrap_or_default(),
180        component_asset_roots: options.component_asset_roots.unwrap_or_default(),
181        css_file_name_template: options
182            .css_file_name_template
183            .unwrap_or_else(|| webui::DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string()),
184        css_public_base: options.css_public_base,
185        legal_comments,
186        theme,
187        projection_manifests,
188    };
189
190    let result = webui::build(build_options)
191        .map_err(|e| NapiError::from_reason(format!("Build error: {}", e.chain_message())))?;
192
193    // Flatten css_files into alternating [filename, content, ...] for JS interop
194    let css_files: Vec<String> = result
195        .css_files
196        .into_iter()
197        .flat_map(|(name, content)| [name, content])
198        .collect();
199    let component_asset_files: Vec<String> = result
200        .component_asset_files
201        .into_iter()
202        .flat_map(|file| [file.name, file.content])
203        .collect();
204    let warnings: Vec<String> = result.warnings.iter().map(|d| d.to_string()).collect();
205
206    Ok(JsBuildResult {
207        protocol: Buffer::from(result.protocol_bytes),
208        css_files,
209        component_asset_files,
210        warnings,
211        stats: JsBuildStats {
212            duration_ms: result.stats.duration.as_secs_f64() * 1000.0,
213            fragment_count: result.stats.fragment_count as u32,
214            component_count: result.stats.component_count as u32,
215            css_file_count: result.stats.css_file_count as u32,
216            protocol_size_bytes: result.stats.protocol_size_bytes as u32,
217            token_count: result.stats.token_count as u32,
218        },
219    })
220}
221
222fn load_theme(theme: &str, search_root: &std::path::Path) -> napi::Result<webui::TokenFile> {
223    let resolved = webui::resolve_theme_path(theme, search_root)
224        .map_err(|e| NapiError::from_reason(format!("Theme resolution error: {e}")))?;
225    webui::load_token_file(&resolved).map_err(|e| {
226        NapiError::from_reason(format!("Theme load error for {}: {e}", resolved.display()))
227    })
228}
229
230/// Inspect protocol bytes and return a JSON representation.
231#[napi]
232pub fn inspect(protocol_data: Buffer) -> napi::Result<String> {
233    webui::inspect_bytes(&protocol_data)
234        .map_err(|e| NapiError::from_reason(format!("Inspect error: {e}")))
235}
236
237/// A decoded protocol and its reusable deterministic indices.
238///
239/// Create this once when a Node server loads `protocol.bin`, then reuse it for
240/// full renders, partial navigation, component loading, and token queries. The
241/// selected hydration plugin is bound once at construction.
242#[napi]
243pub struct Protocol {
244    inner: HandlerProtocol,
245    handler: WebUIHandler,
246}
247
248#[napi]
249impl Protocol {
250    /// Decode a protocol and bind its render plugin for repeated rendering.
251    #[napi(constructor)]
252    pub fn new(protocol_data: Buffer, plugin: Option<String>) -> napi::Result<Self> {
253        let inner = decode_protocol(&protocol_data)?;
254        let handler = create_handler(plugin)?;
255        Ok(Self { inner, handler })
256    }
257
258    /// Render from an existing JSON string into a UTF-8 Node.js buffer.
259    #[napi]
260    pub fn render(
261        &self,
262        state_json: String,
263        entry: String,
264        request_path: String,
265    ) -> napi::Result<Buffer> {
266        let state = parse_state_json(&state_json)?;
267        let options = RenderOptions::new(&entry, &request_path);
268        let html = render_to_string(&self.handler, &self.inner, &state, &options)?;
269        // napi-rs can expose the existing Vec as an external Buffer on Node.
270        Ok(Buffer::from(html.into_bytes()))
271    }
272
273    /// Stream an existing JSON string in bounded chunks.
274    #[napi]
275    pub fn render_stream(
276        &self,
277        state_json: String,
278        entry: String,
279        request_path: String,
280        on_chunk: Function<String, ()>,
281    ) -> napi::Result<()> {
282        let state = parse_state_json(&state_json)?;
283        let options = RenderOptions::new(&entry, &request_path);
284        render_to_callback(&self.handler, &self.inner, &state, &options, &on_chunk)
285    }
286
287    /// Produce a complete partial-navigation response.
288    #[napi]
289    pub fn render_partial(
290        &self,
291        state_json: String,
292        entry_id: String,
293        request_path: String,
294        inventory_hex: String,
295    ) -> napi::Result<String> {
296        self.inner
297            .render_partial(&state_json, &entry_id, &request_path, &inventory_hex)
298            .map_err(|e| NapiError::from_reason(format!("render_partial failed: {e}")))
299    }
300
301    /// Render component templates and styles for on-demand loading.
302    #[napi]
303    pub fn render_component_templates(
304        &self,
305        component_tags: Vec<String>,
306        inventory_hex: String,
307    ) -> napi::Result<String> {
308        let tag_refs: Vec<&str> = component_tags.iter().map(String::as_str).collect();
309        let result = self
310            .inner
311            .render_component_templates(&tag_refs, &inventory_hex)
312            .map_err(|e| {
313                NapiError::from_reason(format!("render_component_templates failed: {e}"))
314            })?;
315        serde_json::to_string(&result)
316            .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
317    }
318
319    /// Return CSS token names in build order.
320    #[napi]
321    pub fn tokens(&self) -> Vec<String> {
322        self.inner.tokens().to_vec()
323    }
324}
325
326fn decode_protocol(protocol_data: &[u8]) -> napi::Result<HandlerProtocol> {
327    HandlerProtocol::from_protobuf(protocol_data)
328        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))
329}
330
331fn parse_state_json(state_json: &str) -> napi::Result<Value> {
332    serde_json::from_str(state_json)
333        .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))
334}
335
336fn create_handler(plugin: Option<String>) -> napi::Result<WebUIHandler> {
337    let plugin = plugin
338        .map(|value| value.parse::<webui::Plugin>())
339        .transpose()
340        .map_err(NapiError::from_reason)?;
341    Ok(match plugin {
342        Some(webui::Plugin::Fast | webui::Plugin::FastV2) => {
343            WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
344        }
345        Some(webui::Plugin::FastV3) => {
346            WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
347        }
348        Some(webui::Plugin::WebUI) => {
349            WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
350        }
351        None => WebUIHandler::new(),
352    })
353}
354
355struct BufferedWriter {
356    output: String,
357}
358
359impl BufferedWriter {
360    fn new() -> Self {
361        Self {
362            output: String::with_capacity(4096),
363        }
364    }
365}
366
367impl ResponseWriter for BufferedWriter {
368    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
369        self.output.push_str(content);
370        Ok(())
371    }
372
373    fn end(&mut self) -> webui_handler::Result<()> {
374        Ok(())
375    }
376}
377
378fn render_to_string(
379    handler: &WebUIHandler,
380    protocol: &HandlerProtocol,
381    state: &Value,
382    options: &RenderOptions<'_>,
383) -> napi::Result<String> {
384    let mut writer = BufferedWriter::new();
385    handler
386        .render(protocol, state, options, &mut writer)
387        .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
388    Ok(writer.output)
389}
390
391/// A writer that batches rendered fragments before crossing into JavaScript.
392struct CallbackWriter<'a, 'env> {
393    callback: &'a Function<'env, String, ()>,
394    buffer: String,
395    error: Option<String>,
396}
397
398impl<'a, 'env> CallbackWriter<'a, 'env> {
399    fn new(callback: &'a Function<'env, String, ()>) -> Self {
400        Self {
401            callback,
402            buffer: String::with_capacity(STREAM_CHUNK_SIZE),
403            error: None,
404        }
405    }
406
407    fn flush(&mut self) {
408        if self.buffer.is_empty() || self.error.is_some() {
409            return;
410        }
411
412        let chunk = std::mem::replace(&mut self.buffer, String::with_capacity(STREAM_CHUNK_SIZE));
413        if let Err(error) = self.callback.call(chunk) {
414            // Ignore errors from callbacks that return non-void
415            // (for example, Node's response.write() returns a boolean).
416            let message = error.to_string();
417            if !message.contains("Value is not undefined") {
418                self.error = Some(message);
419            }
420        }
421    }
422}
423
424impl ResponseWriter for CallbackWriter<'_, '_> {
425    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
426        if self.error.is_some() {
427            return Ok(());
428        }
429        self.buffer.push_str(content);
430        if self.buffer.len() >= STREAM_CHUNK_SIZE {
431            self.flush();
432        }
433        Ok(())
434    }
435
436    fn end(&mut self) -> webui_handler::Result<()> {
437        self.flush();
438        Ok(())
439    }
440}
441
442fn render_to_callback(
443    handler: &WebUIHandler,
444    protocol: &HandlerProtocol,
445    state: &Value,
446    options: &RenderOptions<'_>,
447    on_chunk: &Function<String, ()>,
448) -> napi::Result<()> {
449    let mut writer = CallbackWriter::new(on_chunk);
450    handler
451        .render(protocol, state, options, &mut writer)
452        .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
453    writer.flush();
454
455    if let Some(error) = writer.error {
456        return Err(NapiError::from_reason(format!("Callback error: {error}")));
457    }
458    Ok(())
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use std::collections::BTreeMap;
465    use webui_parser::HtmlParser;
466    use webui_protocol::projection_manifest::{
467        ProjectionAdapter, ProjectionComponent, ProjectionManifest, ProjectionProducer,
468        PRODUCER_NAME, SCHEMA_ID,
469    };
470
471    /// Helper: parse HTML into protobuf bytes for testing.
472    fn build_protocol(html: &str) -> Vec<u8> {
473        let mut parser = HtmlParser::new();
474        parser.parse("index.html", html).expect("parse failed");
475        let tokens = parser.take_tokens();
476        let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
477        protocol.to_protobuf().expect("protobuf encode failed")
478    }
479
480    /// Helper: render protocol bytes + state, collecting output into a String.
481    fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
482        let protocol = HandlerProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
483        let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
484
485        let mut output = String::with_capacity(1024);
486        let handler = WebUIHandler::new();
487
488        struct StringWriter<'a> {
489            output: &'a mut String,
490        }
491        impl ResponseWriter for StringWriter<'_> {
492            fn write(&mut self, content: &str) -> webui_handler::Result<()> {
493                self.output.push_str(content);
494                Ok(())
495            }
496            fn end(&mut self) -> webui_handler::Result<()> {
497                Ok(())
498            }
499        }
500
501        let mut writer = StringWriter {
502            output: &mut output,
503        };
504        handler
505            .render(
506                &protocol,
507                &state,
508                &RenderOptions::new("index.html", "/"),
509                &mut writer,
510            )
511            .map_err(|e| e.to_string())?;
512        Ok(output)
513    }
514
515    #[test]
516    fn test_simple_passthrough() {
517        let proto = build_protocol("<p>Hello</p>");
518        let result = render_to_string(&proto, "{}");
519        assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
520    }
521
522    #[test]
523    fn test_signal_substitution() {
524        let proto = build_protocol("Hello, {{name}}!");
525        let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
526        assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
527    }
528
529    #[test]
530    fn protocol_reuses_decoded_protocol_for_json_state() {
531        let proto = build_protocol("Hello, {{name}}!");
532        let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
533
534        let first = protocol
535            .render(
536                r#"{"name":"First"}"#.to_string(),
537                "index.html".to_string(),
538                "/".to_string(),
539            )
540            .expect("first render should succeed");
541        let second = protocol
542            .render(
543                r#"{"name":"世界"}"#.to_string(),
544                "index.html".to_string(),
545                "/".to_string(),
546            )
547            .expect("second render should succeed");
548
549        assert_eq!(first.as_ref(), b"Hello, First!");
550        assert_eq!(second.as_ref(), "Hello, 世界!".as_bytes());
551    }
552
553    #[test]
554    fn test_for_loop() {
555        let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
556        let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
557        assert_eq!(
558            result.as_deref(),
559            Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
560        );
561    }
562
563    #[test]
564    fn test_if_condition_true() {
565        let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
566        let result = render_to_string(&proto, r#"{"show": true}"#);
567        assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
568    }
569
570    #[test]
571    fn test_if_condition_false() {
572        let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
573        let result = render_to_string(&proto, r#"{"show": false}"#);
574        assert_eq!(result.as_deref(), Ok(""));
575    }
576
577    #[test]
578    fn test_html_escaping() {
579        let proto = build_protocol("<div>{{content}}</div>");
580        let state = r#"{"content": "<script>alert('xss')</script>"}"#;
581        let result = render_to_string(&proto, state).expect("render should succeed");
582        assert!(!result.contains("<script>"));
583        assert!(result.contains("&lt;script&gt;"));
584    }
585
586    #[test]
587    fn test_raw_signal() {
588        let proto = build_protocol("<div>{{{content}}}</div>");
589        let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
590        assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
591    }
592
593    #[test]
594    fn test_invalid_json() {
595        let proto = build_protocol("<p>hi</p>");
596        let result = render_to_string(&proto, "NOT JSON");
597        assert!(result.is_err());
598    }
599
600    #[test]
601    fn test_empty_state() {
602        let proto = build_protocol("<p>static</p>");
603        let result = render_to_string(&proto, "{}");
604        assert_eq!(result.as_deref(), Ok("<p>static</p>"));
605    }
606
607    #[test]
608    fn test_nested_object_signal() {
609        let proto = build_protocol("{{user.name}}");
610        let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
611        assert_eq!(result.as_deref(), Ok("Alice"));
612    }
613
614    #[test]
615    fn test_invalid_protobuf() {
616        let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
617        assert!(result.is_err());
618    }
619
620    /// Parse `html`, attach a sorted hydration `schema`, and encode to protobuf.
621    fn build_projected_protocol(html: &str, schema: &[&str]) -> Vec<u8> {
622        let mut parser = HtmlParser::new();
623        parser.parse("index.html", html).expect("parse failed");
624        let tokens = parser.take_tokens();
625        let mut protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
626        protocol.fragments.insert(
627            "client-card".to_string(),
628            webui_protocol::FragmentList {
629                fragments: vec![webui_protocol::WebUIFragment::raw("<p>client</p>")],
630            },
631        );
632        protocol
633            .fragments
634            .get_mut("index.html")
635            .expect("index fragment should exist")
636            .fragments
637            .insert(1, webui_protocol::WebUIFragment::component("client-card"));
638        protocol.initial_state_strategy = webui_protocol::InitialStateStrategy::Components as i32;
639        protocol.components.insert(
640            "client-card".to_string(),
641            webui_protocol::ComponentData {
642                hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
643                hydration_keys: schema.iter().map(|key| (*key).to_string()).collect(),
644                ..Default::default()
645            },
646        );
647        protocol.to_protobuf().expect("protobuf encode failed")
648    }
649
650    /// Render protocol bytes with the WebUI hydration plugin so the `#webui-data`
651    /// bootstrap block (and its projected state) is emitted — this mirrors the
652    /// production `render(..., plugin = "webui")` path.
653    fn render_with_webui_plugin(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
654        let protocol = HandlerProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
655        let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
656
657        let mut output = String::with_capacity(1024);
658        struct StringWriter<'a> {
659            output: &'a mut String,
660        }
661        impl ResponseWriter for StringWriter<'_> {
662            fn write(&mut self, content: &str) -> webui_handler::Result<()> {
663                self.output.push_str(content);
664                Ok(())
665            }
666            fn end(&mut self) -> webui_handler::Result<()> {
667                Ok(())
668            }
669        }
670        let mut writer = StringWriter {
671            output: &mut output,
672        };
673        let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
674        handler
675            .render(
676                &protocol,
677                &state,
678                &RenderOptions::new("index.html", "/"),
679                &mut writer,
680            )
681            .map_err(|e| e.to_string())?;
682        Ok(output)
683    }
684
685    #[test]
686    fn render_projects_state_to_component_hydration_keys() {
687        // Full document so the parser emits a `body_end` signal, which makes the
688        // WebUI plugin emit the #webui-data bootstrap block.
689        let bytes =
690            build_projected_protocol("<html><body><p>{{kept}}</p></body></html>", &["kept"]);
691        let out =
692            render_with_webui_plugin(&bytes, r#"{"kept":"KEPT_VALUE","dropped":"DROPPED_VALUE"}"#)
693                .expect("render should succeed");
694
695        // Only the hydratable key reaches the bootstrap state block...
696        assert!(
697            out.contains(r#""kept":"KEPT_VALUE""#),
698            "hydratable key missing from bootstrap state: {out}"
699        );
700        // ...the non-hydratable key is projected out entirely.
701        assert!(
702            !out.contains("DROPPED_VALUE"),
703            "server-only value leaked: {out}"
704        );
705        assert!(
706            !out.contains("dropped"),
707            "server-only key name leaked: {out}"
708        );
709    }
710
711    // ── Tests for build() and inspect() napi exports ─────────────────
712
713    fn projection_manifest_json() -> String {
714        const EMPTY_SHA256: &str =
715            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
716        let mut manifest = ProjectionManifest {
717            schema: SCHEMA_ID.to_string(),
718            producer: ProjectionProducer {
719                name: PRODUCER_NAME.to_string(),
720                version: "0.0.18".to_string(),
721            },
722            adapter: ProjectionAdapter {
723                name: "test".to_string(),
724                bundler: "test@1.0.0".to_string(),
725            },
726            root: ".".to_string(),
727            analysis_hash: format!("sha256:{}", "1".repeat(64)),
728            build_id: String::new(),
729            inputs: BTreeMap::from([("demo-card.ts".to_string(), EMPTY_SHA256.to_string())]),
730            outputs: BTreeMap::from([("bundle.js".to_string(), EMPTY_SHA256.to_string())]),
731            components: BTreeMap::from([(
732                "demo-card".to_string(),
733                ProjectionComponent {
734                    module: "demo-card.ts".to_string(),
735                    outputs: vec!["bundle.js".to_string()],
736                    hydration_keys: vec!["name".to_string()],
737                    navigation_keys: vec!["label".to_string(), "name".to_string()],
738                },
739            )]),
740        };
741        manifest.build_id = manifest.compute_build_id();
742        serde_json::to_string(&manifest).unwrap()
743    }
744
745    fn projection_build_options(app_dir: &std::path::Path) -> JsBuildOptions {
746        JsBuildOptions {
747            app_dir: app_dir.to_string_lossy().to_string(),
748            entry: None,
749            css: None,
750            dom: None,
751            plugin: Some("webui".to_string()),
752            components: None,
753            component_asset_roots: None,
754            css_file_name_template: None,
755            css_public_base: None,
756            legal_comments: None,
757            theme: None,
758            projection_manifests: None,
759            projection_manifest_objects: None,
760        }
761    }
762
763    #[test]
764    fn test_build_accepts_projection_paths_and_inline_objects() {
765        let dir = tempfile::TempDir::new().unwrap();
766        std::fs::write(dir.path().join("index.html"), "<demo-card></demo-card>").unwrap();
767        std::fs::write(dir.path().join("demo-card.html"), "<p>{{name}}</p>").unwrap();
768        std::fs::write(dir.path().join("demo-card.ts"), "").unwrap();
769        std::fs::write(dir.path().join("bundle.js"), "").unwrap();
770        let manifest_path = dir.path().join("projection.json");
771        let json = projection_manifest_json();
772        std::fs::write(&manifest_path, &json).unwrap();
773
774        let mut path_options = projection_build_options(dir.path());
775        path_options.projection_manifests = Some(vec![manifest_path.to_string_lossy().to_string()]);
776        let path_result = build(path_options).unwrap();
777        let path_protocol = WebUIProtocol::from_protobuf(&path_result.protocol).unwrap();
778        assert_eq!(
779            path_protocol.components["demo-card"].hydration_keys,
780            ["name"]
781        );
782
783        std::fs::remove_file(&manifest_path).unwrap();
784        let mut inline_options = projection_build_options(dir.path());
785        inline_options.projection_manifest_objects = Some(vec![JsProjectionManifest {
786            path: manifest_path.to_string_lossy().to_string(),
787            json,
788        }]);
789        let inline_result = build(inline_options).unwrap();
790        let inline_protocol = WebUIProtocol::from_protobuf(&inline_result.protocol).unwrap();
791        assert_eq!(
792            inline_protocol.components["demo-card"].navigation_keys,
793            ["label", "name"]
794        );
795    }
796
797    #[test]
798    fn test_build_simple_app() {
799        let dir = tempfile::TempDir::new().unwrap();
800        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
801
802        let options = JsBuildOptions {
803            app_dir: dir.path().to_string_lossy().to_string(),
804            entry: None,
805            css: None,
806            dom: None,
807            plugin: None,
808            components: None,
809            component_asset_roots: None,
810            css_file_name_template: None,
811            css_public_base: None,
812            legal_comments: None,
813            theme: None,
814            projection_manifests: None,
815            projection_manifest_objects: None,
816        };
817
818        let result = build(options).unwrap();
819        assert!(!result.protocol.is_empty());
820        assert!(result.stats.fragment_count > 0);
821        assert!(result.stats.protocol_size_bytes > 0);
822        assert!(result.stats.duration_ms >= 0.0);
823    }
824
825    #[test]
826    fn test_build_with_custom_entry() {
827        let dir = tempfile::TempDir::new().unwrap();
828        std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
829
830        let options = JsBuildOptions {
831            app_dir: dir.path().to_string_lossy().to_string(),
832            entry: Some("page.html".to_string()),
833            css: None,
834            dom: None,
835            plugin: None,
836            components: None,
837            component_asset_roots: None,
838            css_file_name_template: None,
839            css_public_base: None,
840            legal_comments: None,
841            theme: None,
842            projection_manifests: None,
843            projection_manifest_objects: None,
844        };
845
846        let result = build(options).unwrap();
847        assert!(!result.protocol.is_empty());
848    }
849
850    #[test]
851    fn test_build_missing_app_dir() {
852        let options = JsBuildOptions {
853            app_dir: "/nonexistent/path".to_string(),
854            entry: None,
855            css: None,
856            dom: None,
857            plugin: None,
858            components: None,
859            component_asset_roots: None,
860            css_file_name_template: None,
861            css_public_base: None,
862            legal_comments: None,
863            theme: None,
864            projection_manifests: None,
865            projection_manifest_objects: None,
866        };
867
868        let result = build(options);
869        assert!(result.is_err());
870    }
871
872    #[test]
873    fn test_build_invalid_css() {
874        let dir = tempfile::TempDir::new().unwrap();
875        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
876
877        let options = JsBuildOptions {
878            app_dir: dir.path().to_string_lossy().to_string(),
879            entry: None,
880            css: Some("bogus".to_string()),
881            dom: None,
882            plugin: None,
883            components: None,
884            component_asset_roots: None,
885            css_file_name_template: None,
886            css_public_base: None,
887            legal_comments: None,
888            theme: None,
889            projection_manifests: None,
890            projection_manifest_objects: None,
891        };
892
893        let result = build(options);
894        assert!(result.is_err());
895    }
896
897    #[test]
898    fn test_build_with_components_css() {
899        let dir = tempfile::TempDir::new().unwrap();
900        std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
901        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
902        std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
903
904        let options = JsBuildOptions {
905            app_dir: dir.path().to_string_lossy().to_string(),
906            entry: None,
907            css: Some("link".to_string()),
908            dom: None,
909            plugin: None,
910            components: None,
911            component_asset_roots: None,
912            css_file_name_template: None,
913            css_public_base: None,
914            legal_comments: None,
915            theme: None,
916            projection_manifests: None,
917            projection_manifest_objects: None,
918        };
919
920        let result = build(options).unwrap();
921        // css_files is flattened: [filename, content, filename, content, ...]
922        assert_eq!(result.css_files.len(), 2);
923        assert_eq!(result.css_files[0], "my-card.css");
924        assert!(result.css_files[1].contains("color: red"));
925        assert_eq!(result.stats.css_file_count, 1);
926    }
927
928    #[test]
929    fn test_build_with_theme_missing_token_errors() {
930        let dir = tempfile::TempDir::new().unwrap();
931        std::fs::write(dir.path().join("index.html"), "<my-card></my-card>").unwrap();
932        std::fs::write(dir.path().join("my-card.html"), "<div>Card</div>").unwrap();
933        std::fs::write(
934            dir.path().join("my-card.css"),
935            ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
936        )
937        .unwrap();
938        let theme_path = dir.path().join("theme.json");
939        std::fs::write(&theme_path, r#"{"themes":{"light":{"token-b":"green"}}}"#).unwrap();
940
941        let options = JsBuildOptions {
942            app_dir: dir.path().to_string_lossy().to_string(),
943            entry: None,
944            css: Some("link".to_string()),
945            dom: None,
946            plugin: None,
947            components: None,
948            component_asset_roots: None,
949            css_file_name_template: None,
950            css_public_base: None,
951            legal_comments: None,
952            theme: Some(theme_path.to_string_lossy().to_string()),
953            projection_manifests: None,
954            projection_manifest_objects: None,
955        };
956
957        let Err(err) = build(options) else {
958            panic!("missing theme token must fail");
959        };
960        let message = err.to_string();
961        assert!(message.contains("missing-theme-token"), "msg: {message}");
962        assert!(message.contains("--token-c"), "msg: {message}");
963    }
964
965    #[test]
966    fn test_build_returns_component_asset_files() {
967        let dir = tempfile::TempDir::new().unwrap();
968        std::fs::write(dir.path().join("index.html"), "<app-shell></app-shell>").unwrap();
969        std::fs::write(dir.path().join("app-shell.html"), "<div></div>").unwrap();
970        std::fs::write(dir.path().join("lazy-panel.html"), "<p>{{title}}</p>").unwrap();
971        std::fs::write(dir.path().join("lazy-panel.ts"), "export {};").unwrap();
972
973        let options = JsBuildOptions {
974            app_dir: dir.path().to_string_lossy().to_string(),
975            entry: None,
976            css: Some("link".to_string()),
977            dom: None,
978            plugin: Some("webui".to_string()),
979            components: None,
980            component_asset_roots: Some(vec!["lazy-panel".to_string()]),
981            css_file_name_template: None,
982            css_public_base: None,
983            legal_comments: None,
984            theme: None,
985            projection_manifests: None,
986            projection_manifest_objects: None,
987        };
988
989        let result = build(options).unwrap();
990
991        assert_eq!(result.component_asset_files.len(), 2);
992        assert_eq!(result.component_asset_files[0], "lazy-panel.webui.js");
993        assert!(result.component_asset_files[1].contains("webui-component-asset"));
994        assert!(result.component_asset_files[1].contains("export default asset;"));
995    }
996
997    #[test]
998    fn test_build_legal_comments_none_strips_legal_css() {
999        let dir = tempfile::TempDir::new().unwrap();
1000        std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
1001        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
1002        std::fs::write(
1003            dir.path().join("my-card.css"),
1004            "/*! @license MIT */ .card { color: red; }",
1005        )
1006        .unwrap();
1007
1008        let options = JsBuildOptions {
1009            app_dir: dir.path().to_string_lossy().to_string(),
1010            entry: None,
1011            css: Some("link".to_string()),
1012            dom: None,
1013            plugin: None,
1014            components: None,
1015            component_asset_roots: None,
1016            css_file_name_template: None,
1017            css_public_base: None,
1018            legal_comments: Some("none".to_string()),
1019            theme: None,
1020            projection_manifests: None,
1021            projection_manifest_objects: None,
1022        };
1023
1024        let result = build(options).unwrap();
1025        assert_eq!(result.css_files[1], " .card { color: red; }");
1026    }
1027
1028    #[test]
1029    fn test_build_invalid_legal_comments() {
1030        let dir = tempfile::TempDir::new().unwrap();
1031        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1032
1033        let options = JsBuildOptions {
1034            app_dir: dir.path().to_string_lossy().to_string(),
1035            entry: None,
1036            css: None,
1037            dom: None,
1038            plugin: None,
1039            components: None,
1040            component_asset_roots: None,
1041            css_file_name_template: None,
1042            css_public_base: None,
1043            legal_comments: Some("linked".to_string()),
1044            theme: None,
1045            projection_manifests: None,
1046            projection_manifest_objects: None,
1047        };
1048
1049        let result = build(options);
1050        assert!(result.is_err());
1051    }
1052
1053    #[test]
1054    fn test_build_with_light_dom_omits_shadow_root_template() {
1055        let dir = tempfile::TempDir::new().unwrap();
1056        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1057        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
1058
1059        let options = JsBuildOptions {
1060            app_dir: dir.path().to_string_lossy().to_string(),
1061            entry: None,
1062            css: None,
1063            dom: Some("light".to_string()),
1064            plugin: None,
1065            components: None,
1066            component_asset_roots: None,
1067            css_file_name_template: None,
1068            css_public_base: None,
1069            legal_comments: None,
1070            theme: None,
1071            projection_manifests: None,
1072            projection_manifest_objects: None,
1073        };
1074
1075        let result = build(options).unwrap();
1076        let json = inspect(result.protocol).unwrap();
1077        assert!(
1078            !json.contains("shadowrootmode"),
1079            "light DOM build should not emit shadowrootmode wrappers, got: {json}"
1080        );
1081    }
1082
1083    #[test]
1084    fn test_build_with_shadow_dom_emits_shadow_root_template() {
1085        let dir = tempfile::TempDir::new().unwrap();
1086        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1087        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
1088
1089        let options = JsBuildOptions {
1090            app_dir: dir.path().to_string_lossy().to_string(),
1091            entry: None,
1092            css: None,
1093            dom: Some("shadow".to_string()),
1094            plugin: None,
1095            components: None,
1096            component_asset_roots: None,
1097            css_file_name_template: None,
1098            css_public_base: None,
1099            legal_comments: None,
1100            theme: None,
1101            projection_manifests: None,
1102            projection_manifest_objects: None,
1103        };
1104
1105        let result = build(options).unwrap();
1106        let json = inspect(result.protocol).unwrap();
1107        assert!(
1108            json.contains("shadowrootmode"),
1109            "shadow DOM build should emit shadowrootmode wrappers, got: {json}"
1110        );
1111    }
1112
1113    #[test]
1114    fn test_build_invalid_dom() {
1115        let dir = tempfile::TempDir::new().unwrap();
1116        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1117
1118        let options = JsBuildOptions {
1119            app_dir: dir.path().to_string_lossy().to_string(),
1120            entry: None,
1121            css: None,
1122            dom: Some("bogus".to_string()),
1123            plugin: None,
1124            components: None,
1125            component_asset_roots: None,
1126            css_file_name_template: None,
1127            css_public_base: None,
1128            legal_comments: None,
1129            theme: None,
1130            projection_manifests: None,
1131            projection_manifest_objects: None,
1132        };
1133
1134        let result = build(options);
1135        assert!(result.is_err());
1136    }
1137
1138    #[test]
1139    fn test_inspect_valid_protocol() {
1140        let proto = build_protocol("<h1>Hello {{name}}</h1>");
1141        let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
1142        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1143        assert!(parsed.get("fragments").is_some());
1144    }
1145
1146    #[test]
1147    fn test_inspect_invalid_protocol() {
1148        let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
1149        assert!(result.is_err());
1150    }
1151
1152    #[test]
1153    fn test_protocol_tokens_empty() {
1154        let proto = build_protocol("<p>Hello</p>");
1155        let protocol = Protocol::new(Buffer::from(proto), None).unwrap();
1156        let tokens = protocol.tokens();
1157        assert!(tokens.is_empty());
1158    }
1159
1160    #[test]
1161    fn test_protocol_tokens_returns_parsed_tokens() {
1162        // Build from a protocol that has CSS tokens via with_tokens constructor.
1163        let mut parser = HtmlParser::new();
1164        parser.parse("index.html", "<p>Hi</p>").expect("parse");
1165        let protocol = WebUIProtocol::with_tokens(
1166            parser.into_fragment_records(),
1167            vec![
1168                "colorBrandBackground".to_string(),
1169                "fontSizeBase300".to_string(),
1170            ],
1171        );
1172        let proto = protocol.to_protobuf().expect("encode");
1173        let protocol = Protocol::new(Buffer::from(proto), None).unwrap();
1174        let tokens = protocol.tokens();
1175        assert_eq!(tokens, vec!["colorBrandBackground", "fontSizeBase300"]);
1176    }
1177
1178    #[test]
1179    fn test_protocol_tokens_invalid_protobuf() {
1180        let result = Protocol::new(Buffer::from(vec![0xFF, 0xFF]), None);
1181        assert!(result.is_err());
1182    }
1183}