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