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, Either, External, 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    BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, HandlerError,
41    Protocol as HandlerProtocol, RenderCapacityHints, RenderOptions, ResponseWriter,
42    SessionOptions, StreamStep as HandlerStreamStep, StreamingSession as HandlerStreamingSession,
43    WebUIHandler,
44};
45#[cfg(test)]
46use webui_protocol::WebUIProtocol;
47
48const STREAM_CHUNK_SIZE: usize = 16 * 1024;
49
50/// Build statistics returned from the build function.
51#[napi(object)]
52pub struct JsBuildStats {
53    /// Build duration in milliseconds.
54    pub duration_ms: f64,
55    /// Total number of protocol fragments.
56    pub fragment_count: u32,
57    /// Number of registered components.
58    pub component_count: u32,
59    /// Number of CSS files produced.
60    pub css_file_count: u32,
61    /// Size of the serialized protocol in bytes.
62    pub protocol_size_bytes: u32,
63    /// Number of unique CSS tokens discovered.
64    pub token_count: u32,
65}
66
67/// Result of a successful build operation.
68#[napi(object)]
69pub struct JsBuildResult {
70    /// Serialized protocol (protobuf binary).
71    pub protocol: Buffer,
72    /// CSS files as alternating [filename, content, filename, content, ...].
73    pub css_files: Vec<String>,
74    /// Static component asset files as alternating [filename, content, filename, content, ...].
75    pub component_asset_files: Vec<String>,
76    /// Esbuild-compatible component asset metafile JSON when requested.
77    pub metafile: Option<String>,
78    /// Non-fatal build advisories (plain text), e.g. CSS tokens used only with a
79    /// literal `var()` fallback and absent from every theme.
80    pub warnings: Vec<String>,
81    /// Build statistics.
82    pub stats: JsBuildStats,
83}
84
85/// Inline projection manifest transported through N-API.
86#[napi(object)]
87pub struct JsProjectionManifest {
88    /// Logical manifest path used to resolve `root` and stale file checks.
89    pub path: String,
90    /// Canonical manifest JSON.
91    pub json: String,
92}
93
94/// Build options for the webui build API.
95#[napi(object)]
96pub struct JsBuildOptions {
97    /// Path to the application folder containing templates.
98    pub app_dir: String,
99    /// Entry HTML file name (defaults to "index.html").
100    pub entry: Option<String>,
101    /// CSS mode: "link" (default), "style", or "module".
102    pub css: Option<String>,
103    /// Fallback DOM strategy: "shadow" (default) or "light".
104    pub dom: Option<String>,
105    /// Merge component stylesheets into shared bundled chunks.
106    ///
107    /// Composes with `css`: bundling decides how stylesheets are grouped, `css`
108    /// decides how they reach the page.
109    pub css_bundle: Option<bool>,
110    /// Plugin identifier (see crate documentation for available identifiers).
111    pub plugin: Option<String>,
112    /// Additional component sources (npm packages or local paths).
113    pub components: Option<Vec<String>>,
114    /// Root component tags emitted as static `.webui.js` ESM assets.
115    pub component_asset_roots: Option<Vec<String>>,
116    /// Generate and return an esbuild-compatible component asset metafile.
117    pub metafile: Option<bool>,
118    /// Link-mode CSS filename template using [name], [hash], [ext].
119    pub css_file_name_template: Option<String>,
120    /// Optional base URL/path prefix for Link-mode css hrefs.
121    pub css_public_base: Option<String>,
122    /// Legal comment handling: "inline" (default) or "none".
123    pub legal_comments: Option<String>,
124    /// Design token theme: a JSON file path or npm package name.
125    pub theme: Option<String>,
126    /// Projection manifest paths.
127    pub projection_manifests: Option<Vec<String>>,
128    /// Inline manifest objects with their logical paths.
129    pub projection_manifest_objects: Option<Vec<JsProjectionManifest>>,
130}
131
132/// Build a WebUI application from an app directory.
133///
134/// Returns the compiled protocol bytes, CSS files, and build statistics.
135#[napi]
136#[allow(clippy::cast_possible_truncation)] // stats are bounded by component/file counts
137pub fn build(options: JsBuildOptions) -> napi::Result<JsBuildResult> {
138    let css = options
139        .css
140        .map(|s| s.parse::<webui::CssStrategy>())
141        .transpose()
142        .map_err(NapiError::from_reason)?
143        .unwrap_or_default();
144    let dom = options
145        .dom
146        .map(|value| value.parse::<webui::DomStrategy>())
147        .transpose()
148        .map_err(NapiError::from_reason)?
149        .unwrap_or_default();
150
151    let plugin = options
152        .plugin
153        .map(|s| s.parse::<webui::Plugin>())
154        .transpose()
155        .map_err(NapiError::from_reason)?;
156
157    let legal_comments = options
158        .legal_comments
159        .map(|s| s.parse::<webui::LegalComments>())
160        .transpose()
161        .map_err(NapiError::from_reason)?
162        .unwrap_or_default();
163
164    let app_dir = std::path::PathBuf::from(&options.app_dir);
165    let theme = options
166        .theme
167        .as_deref()
168        .map(|theme| load_theme(theme, &app_dir))
169        .transpose()?;
170    let mut projection_manifests: Vec<webui::ProjectionManifestSource> = options
171        .projection_manifests
172        .unwrap_or_default()
173        .into_iter()
174        .map(std::path::PathBuf::from)
175        .map(Into::into)
176        .collect();
177    projection_manifests.extend(
178        options
179            .projection_manifest_objects
180            .unwrap_or_default()
181            .into_iter()
182            .map(|manifest| webui::ProjectionManifestSource::Inline {
183                manifest_path: std::path::PathBuf::from(manifest.path),
184                json: manifest.json,
185            }),
186    );
187
188    let build_options = webui::BuildOptions {
189        app_dir,
190        entry: options.entry.unwrap_or_else(|| "index.html".to_string()),
191        css,
192        dom,
193        css_bundle: options.css_bundle.unwrap_or(false),
194        plugin,
195        components: options.components.unwrap_or_default(),
196        component_asset_roots: options.component_asset_roots.unwrap_or_default(),
197        metafile: options.metafile.unwrap_or(false),
198        css_file_name_template: options
199            .css_file_name_template
200            .unwrap_or_else(|| webui::DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string()),
201        css_public_base: options.css_public_base,
202        legal_comments,
203        theme,
204        projection_manifests,
205    };
206
207    let result = webui::build(build_options)
208        .map_err(|e| NapiError::from_reason(format!("Build error: {}", e.chain_message())))?;
209
210    // Flatten css_files into alternating [filename, content, ...] for JS interop
211    let css_files: Vec<String> = result
212        .css_files
213        .into_iter()
214        .flat_map(|(name, content)| [name, content])
215        .collect();
216    let component_asset_files: Vec<String> = result
217        .component_asset_files
218        .into_iter()
219        .flat_map(|file| [file.name, file.content])
220        .collect();
221    let warnings: Vec<String> = result.warnings.iter().map(|d| d.to_string()).collect();
222
223    Ok(JsBuildResult {
224        protocol: Buffer::from(result.protocol_bytes),
225        css_files,
226        component_asset_files,
227        metafile: result.metafile,
228        warnings,
229        stats: JsBuildStats {
230            duration_ms: result.stats.duration.as_secs_f64() * 1000.0,
231            fragment_count: result.stats.fragment_count as u32,
232            component_count: result.stats.component_count as u32,
233            css_file_count: result.stats.css_file_count as u32,
234            protocol_size_bytes: result.stats.protocol_size_bytes as u32,
235            token_count: result.stats.token_count as u32,
236        },
237    })
238}
239
240fn load_theme(theme: &str, search_root: &std::path::Path) -> napi::Result<webui::TokenFile> {
241    let resolved = webui::resolve_theme_path(theme, search_root)
242        .map_err(|e| NapiError::from_reason(format!("Theme resolution error: {e}")))?;
243    webui::load_token_file(&resolved).map_err(|e| {
244        NapiError::from_reason(format!("Theme load error for {}: {e}", resolved.display()))
245    })
246}
247
248/// Inspect protocol bytes and return a JSON representation.
249#[napi]
250pub fn inspect(protocol_data: Buffer) -> napi::Result<String> {
251    webui::inspect_bytes(&protocol_data)
252        .map_err(|e| NapiError::from_reason(format!("Inspect error: {e}")))
253}
254
255/// A decoded protocol and its reusable deterministic indices.
256///
257/// Create this once when a Node server loads `protocol.bin`, then reuse it for
258/// full renders, partial navigation, component loading, and token queries. The
259/// selected hydration plugin is bound once at construction.
260#[napi]
261pub struct Protocol {
262    inner: Arc<HandlerProtocol>,
263    handler: Arc<WebUIHandler>,
264    output_capacity_hints: RenderCapacityHints,
265}
266
267#[napi]
268impl Protocol {
269    /// Decode a protocol and bind its render plugin for repeated rendering.
270    #[napi(constructor)]
271    pub fn new(protocol_data: Buffer, plugin: Option<String>) -> napi::Result<Self> {
272        let inner = Arc::new(decode_protocol(&protocol_data)?);
273        let handler = Arc::new(create_handler(plugin)?);
274        Ok(Self {
275            inner,
276            handler,
277            output_capacity_hints: RenderCapacityHints::new(),
278        })
279    }
280
281    /// Render from an existing JSON string into a UTF-8 Node.js buffer.
282    #[napi]
283    pub fn render(
284        &self,
285        state_json: String,
286        entry: String,
287        request_path: String,
288    ) -> napi::Result<Buffer> {
289        let state = parse_state_json(&state_json)?;
290        let options = RenderOptions::new(&entry, &request_path);
291        self.render_buffer(&state, &options)
292    }
293
294    /// Parse and retain an immutable, process-local state snapshot.
295    ///
296    /// The returned N-API external owns the parsed state until its JavaScript
297    /// handle is garbage-collected.
298    #[napi]
299    pub fn prepare_state(&self, state_json: String) -> napi::Result<External<Value>> {
300        let size_hint = state_json.len().saturating_mul(2);
301        Ok(External::new_with_size_hint(
302            parse_state_json(&state_json)?,
303            size_hint,
304        ))
305    }
306
307    /// Render an immutable state snapshot prepared by this process.
308    #[napi]
309    pub fn render_prepared(
310        &self,
311        state: &External<Value>,
312        entry: String,
313        request_path: String,
314    ) -> napi::Result<Buffer> {
315        let options = RenderOptions::new(&entry, &request_path);
316        self.render_buffer(state.as_ref(), &options)
317    }
318
319    /// Stream an existing JSON string in bounded chunks.
320    #[napi]
321    pub fn render_stream(
322        &self,
323        state_json: String,
324        entry: String,
325        request_path: String,
326        on_chunk: Function<String>,
327    ) -> napi::Result<()> {
328        let state = parse_state_json(&state_json)?;
329        let options = RenderOptions::new(&entry, &request_path);
330        render_to_callback(&self.handler, &self.inner, &state, &options, &on_chunk)
331    }
332
333    /// Produce a complete partial-navigation response.
334    #[napi]
335    pub fn render_partial(
336        &self,
337        state_json: String,
338        entry_id: String,
339        request_path: String,
340        inventory_hex: String,
341    ) -> napi::Result<String> {
342        self.inner
343            .render_partial_json(&state_json, &entry_id, &request_path, &inventory_hex)
344            .map_err(|e| NapiError::from_reason(format!("render_partial failed: {e}")))
345    }
346
347    /// Render component templates and styles for on-demand loading.
348    #[napi]
349    pub fn render_component_templates(
350        &self,
351        component_tags: Vec<String>,
352        inventory_hex: String,
353    ) -> napi::Result<String> {
354        let tag_refs: Vec<&str> = component_tags.iter().map(String::as_str).collect();
355        let result = self
356            .inner
357            .render_component_templates(&tag_refs, &inventory_hex)
358            .map_err(|e| {
359                NapiError::from_reason(format!("render_component_templates failed: {e}"))
360            })?;
361        serde_json::to_string(&result)
362            .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
363    }
364
365    /// Return CSS token names in build order.
366    #[napi]
367    pub fn tokens(&self) -> Vec<String> {
368        self.inner.tokens().to_vec()
369    }
370
371    /// Open a host-driven progressive response for a streaming entry.
372    ///
373    /// Unlike `renderStream`, which pushes every chunk during one synchronous
374    /// call, the returned session hands each chunk back so the Node server
375    /// owns the socket, the write order, and backpressure.
376    #[napi]
377    pub fn stream_response(
378        &self,
379        entry: String,
380        request_path: String,
381        options: Option<JsStreamOptions>,
382    ) -> napi::Result<StreamingSession> {
383        let options = options.unwrap_or_default();
384        let mut session_options = SessionOptions::new(entry, request_path);
385        session_options.nonce = options.nonce;
386        session_options.head_inject = options.head_inject;
387        session_options.body_inject = options.body_inject;
388        let inner = HandlerStreamingSession::new(
389            Arc::clone(&self.handler),
390            Arc::clone(&self.inner),
391            session_options,
392        )
393        .map_err(streaming_error)?;
394        Ok(StreamingSession { inner })
395    }
396}
397
398impl Protocol {
399    fn render_buffer(&self, state: &Value, options: &RenderOptions<'_>) -> napi::Result<Buffer> {
400        let capacity = self
401            .output_capacity_hints
402            .load(options.entry_id, options.request_path);
403        let mut html = render_to_string(&self.handler, &self.inner, state, options, capacity)?;
404        self.output_capacity_hints
405            .store(options.entry_id, options.request_path, html.len());
406        // Bound a stale same-route hint without reallocating normally sized output.
407        if html.capacity() > html.len().saturating_mul(2) {
408            html.shrink_to_fit();
409        }
410        // napi-rs can expose the existing Vec as an external Buffer on Node.
411        Ok(Buffer::from(html.into_bytes()))
412    }
413}
414
415/// Optional per-response streaming settings.
416#[napi(object)]
417#[derive(Default)]
418pub struct JsStreamOptions {
419    /// CSP nonce applied to generated inline `<script>` tags.
420    pub nonce: Option<String>,
421    /// HTML injected at the structural `head_end` boundary.
422    pub head_inject: Option<String>,
423    /// HTML injected at the structural `body_end` boundary.
424    pub body_inject: Option<String>,
425}
426
427/// A progressive HTML response driven one semantic step at a time from Node.
428///
429/// `start()`, `resume()`, and `advance()` return bytes, completion state, and
430/// the next runtime boundary occurrence (if any). Boundary keys retain their
431/// authored JSON type: strings are JavaScript strings and finite numbers are
432/// JavaScript numbers.
433///
434/// ```js
435/// const session = protocol.streamResponse('index.html', '/');
436/// let step = session.start(JSON.stringify(shellState));
437/// res.write(step.bytes);
438/// while (!step.done) {
439///   const { instanceId, name, key } = step.boundary;
440///   const state = await loadBoundary(name, key);
441///   step = session.resume(instanceId, JSON.stringify(state), 'updatable');
442///   res.write(step.bytes);
443///   step = session.advance();
444///   res.write(step.bytes);
445/// }
446/// ```
447#[napi]
448pub struct StreamingSession {
449    inner: HandlerStreamingSession,
450}
451
452/// Runtime boundary occurrence returned by a streaming step.
453#[napi(object)]
454pub struct JsBoundaryDescriptor {
455    /// Gapless response-local occurrence ID passed to `resume()` and `update()`.
456    pub instance_id: u32,
457    /// Stable compiler declaration ID.
458    pub declaration_id: u32,
459    /// Entry or component template that owns this declaration.
460    pub owner: String,
461    /// Free-form authored boundary name.
462    pub name: String,
463    /// Evaluated boundary key, preserving string-versus-number identity.
464    pub key: Option<Either<String, f64>>,
465}
466
467/// Bytes and continuation state returned by one streaming session step.
468#[napi(object)]
469pub struct JsStreamStep {
470    /// Complete bytes produced by this semantic step.
471    pub bytes: Buffer,
472    /// Whether the terminal record was emitted.
473    pub done: bool,
474    /// Next runtime boundary occurrence waiting for `resume()`.
475    pub boundary: Option<JsBoundaryDescriptor>,
476}
477
478#[napi]
479impl StreamingSession {
480    /// Render until the first runtime boundary occurrence or terminal.
481    #[napi]
482    pub fn start(&mut self, state_json: String) -> napi::Result<JsStreamStep> {
483        let state = parse_state_json(&state_json)?;
484        self.inner
485            .start(state)
486            .and_then(stream_step)
487            .map_err(streaming_error)
488    }
489
490    /// Commit the pending occurrence through its checkpoint, then stop.
491    ///
492    /// `mode` is `"final"` (default) or `"updatable"`. Only updatable
493    /// boundaries accept later `update()` calls.
494    #[napi]
495    pub fn resume(
496        &mut self,
497        instance_id: u32,
498        state_json: String,
499        mode: Option<String>,
500    ) -> napi::Result<JsStreamStep> {
501        let state = parse_state_json(&state_json)?;
502        let mode = parse_boundary_mode(mode.as_deref())?;
503        self.inner
504            .resume(BoundaryInstanceId::from_raw(instance_id), state, mode)
505            .and_then(stream_step)
506            .map_err(streaming_error)
507    }
508
509    /// Write the parent bytes after the committed occurrence.
510    ///
511    /// Valid only after `resume()`. Returns the next boundary occurrence or
512    /// completes the document tail.
513    #[napi]
514    pub fn advance(&mut self) -> napi::Result<JsStreamStep> {
515        self.inner
516            .advance()
517            .and_then(stream_step)
518            .map_err(streaming_error)
519    }
520
521    /// Push a projected state patch to a committed updatable boundary.
522    #[napi]
523    pub fn update(&mut self, instance_id: u32, patch_json: String) -> napi::Result<Buffer> {
524        let patch = parse_state_json(&patch_json)?;
525        self.inner
526            .update(BoundaryInstanceId::from_raw(instance_id), &patch)
527            .map(Buffer::from)
528            .map_err(streaming_error)
529    }
530}
531
532fn stream_step(step: HandlerStreamStep) -> webui_handler::Result<JsStreamStep> {
533    Ok(JsStreamStep {
534        bytes: Buffer::from(step.bytes),
535        done: step.done,
536        boundary: step.boundary.map(boundary_descriptor).transpose()?,
537    })
538}
539
540fn boundary_descriptor(
541    boundary: BoundaryDescriptor,
542) -> webui_handler::Result<JsBoundaryDescriptor> {
543    Ok(JsBoundaryDescriptor {
544        instance_id: boundary.instance_id.raw(),
545        declaration_id: boundary.declaration_id,
546        owner: boundary.owner.to_string(),
547        name: boundary.name.to_string(),
548        key: boundary.key.map(boundary_key).transpose()?,
549    })
550}
551
552fn boundary_key(key: BoundaryKey) -> webui_handler::Result<Either<String, f64>> {
553    match key {
554        BoundaryKey::String(value) => Ok(Either::A(value)),
555        BoundaryKey::Number(value) => value.as_f64().map(Either::B).ok_or_else(|| {
556            HandlerError::Invariant(
557                "boundary key cannot be represented as a JavaScript number".into(),
558            )
559        }),
560    }
561}
562
563fn parse_boundary_mode(mode: Option<&str>) -> napi::Result<BoundaryMode> {
564    match mode {
565        None | Some("final") => Ok(BoundaryMode::Final),
566        Some("updatable") => Ok(BoundaryMode::Updatable),
567        Some(other) => Err(NapiError::from_reason(format!(
568            "unknown boundary mode '{other}'; expected 'final' or 'updatable'"
569        ))),
570    }
571}
572
573fn streaming_error(error: HandlerError) -> NapiError {
574    NapiError::from_reason(error.to_string())
575}
576
577fn decode_protocol(protocol_data: &[u8]) -> napi::Result<HandlerProtocol> {
578    HandlerProtocol::from_protobuf(protocol_data)
579        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))
580}
581
582fn parse_state_json(state_json: &str) -> napi::Result<Value> {
583    serde_json::from_str(state_json)
584        .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))
585}
586
587fn create_handler(plugin: Option<String>) -> napi::Result<WebUIHandler> {
588    let plugin = plugin
589        .map(|value| value.parse::<webui::Plugin>())
590        .transpose()
591        .map_err(NapiError::from_reason)?;
592    Ok(match plugin {
593        Some(webui::Plugin::Fast | webui::Plugin::FastV2) => {
594            WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
595        }
596        Some(webui::Plugin::FastV3) => {
597            WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
598        }
599        Some(webui::Plugin::WebUI) => {
600            WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
601        }
602        None => WebUIHandler::new(),
603    })
604}
605
606webui_handler::define_string_response_writer!(BufferedWriter, output);
607
608fn render_to_string(
609    handler: &WebUIHandler,
610    protocol: &HandlerProtocol,
611    state: &Value,
612    options: &RenderOptions<'_>,
613    capacity: usize,
614) -> napi::Result<String> {
615    let mut writer = BufferedWriter::with_capacity(capacity);
616    handler
617        .render(protocol, state, options, &mut writer)
618        .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
619    Ok(writer.output)
620}
621
622/// A writer that batches rendered fragments before crossing into JavaScript.
623struct CallbackWriter<F> {
624    callback: F,
625    buffer: String,
626    error: Option<NapiError>,
627}
628
629impl<F> CallbackWriter<F>
630where
631    F: FnMut(String) -> napi::Result<()>,
632{
633    fn new(callback: F) -> Self {
634        Self {
635            callback,
636            buffer: String::with_capacity(STREAM_CHUNK_SIZE),
637            error: None,
638        }
639    }
640
641    fn flush(&mut self) -> webui_handler::Result<()> {
642        if self.error.is_some() {
643            return Err(callback_writer_error());
644        }
645        if self.buffer.is_empty() {
646            return Ok(());
647        }
648
649        let chunk = std::mem::replace(&mut self.buffer, String::with_capacity(STREAM_CHUNK_SIZE));
650        if let Err(error) = (self.callback)(chunk) {
651            self.error = Some(error);
652            return Err(callback_writer_error());
653        }
654        Ok(())
655    }
656}
657
658impl<F> ResponseWriter for CallbackWriter<F>
659where
660    F: FnMut(String) -> napi::Result<()>,
661{
662    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
663        if self.error.is_some() {
664            return Err(callback_writer_error());
665        }
666        self.buffer.push_str(content);
667        if self.buffer.len() >= STREAM_CHUNK_SIZE {
668            self.flush()?;
669        }
670        Ok(())
671    }
672
673    fn write_attribute(&mut self, name: &str, value: &str) -> webui_handler::Result<()> {
674        if self.error.is_some() {
675            return Err(callback_writer_error());
676        }
677        webui_handler::append_attribute_to_string(&mut self.buffer, name, value);
678        if self.buffer.len() >= STREAM_CHUNK_SIZE {
679            self.flush()?;
680        }
681        Ok(())
682    }
683
684    fn write_boolean_attribute(&mut self, name: &str) -> webui_handler::Result<()> {
685        if self.error.is_some() {
686            return Err(callback_writer_error());
687        }
688        webui_handler::append_boolean_attribute_to_string(&mut self.buffer, name);
689        if self.buffer.len() >= STREAM_CHUNK_SIZE {
690            self.flush()?;
691        }
692        Ok(())
693    }
694
695    fn end(&mut self) -> webui_handler::Result<()> {
696        self.flush()
697    }
698}
699
700#[cold]
701#[inline(never)]
702fn callback_writer_error() -> HandlerError {
703    HandlerError::Writer("Node chunk callback failed".to_owned())
704}
705
706fn render_to_callback(
707    handler: &WebUIHandler,
708    protocol: &HandlerProtocol,
709    state: &Value,
710    options: &RenderOptions<'_>,
711    on_chunk: &Function<String>,
712) -> napi::Result<()> {
713    let mut writer = CallbackWriter::new(|chunk| on_chunk.call(chunk).map(drop));
714    let render_result = handler.render(protocol, state, options, &mut writer);
715    if let Some(error) = writer.error {
716        return Err(error);
717    }
718    render_result.map_err(|e| NapiError::from_reason(format!("Render error: {e}")))
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use std::collections::BTreeMap;
725    use webui_parser::HtmlParser;
726    use webui_protocol::projection_manifest::{
727        ProjectionAdapter, ProjectionComponent, ProjectionManifest, ProjectionProducer,
728        PRODUCER_NAME, SCHEMA_ID,
729    };
730
731    /// Helper: parse HTML into protobuf bytes for testing.
732    fn build_protocol(html: &str) -> Vec<u8> {
733        let mut parser = HtmlParser::new();
734        parser.parse("index.html", html).expect("parse failed");
735        let tokens = parser.take_tokens();
736        let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
737        protocol.to_protobuf().expect("protobuf encode failed")
738    }
739
740    /// Helper: render protocol bytes + state, collecting output into a String.
741    fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
742        let protocol = HandlerProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
743        let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
744
745        let mut output = String::with_capacity(1024);
746        let handler = WebUIHandler::new();
747
748        struct StringWriter<'a> {
749            output: &'a mut String,
750        }
751        impl ResponseWriter for StringWriter<'_> {
752            fn write(&mut self, content: &str) -> webui_handler::Result<()> {
753                self.output.push_str(content);
754                Ok(())
755            }
756            fn end(&mut self) -> webui_handler::Result<()> {
757                Ok(())
758            }
759        }
760
761        let mut writer = StringWriter {
762            output: &mut output,
763        };
764        handler
765            .render(
766                &protocol,
767                &state,
768                &RenderOptions::new("index.html", "/"),
769                &mut writer,
770            )
771            .map_err(|e| e.to_string())?;
772        Ok(output)
773    }
774
775    #[test]
776    fn learned_render_capacity_avoids_string_growth_without_changing_bytes() {
777        struct ReallocationWriter {
778            output: String,
779            reallocations: usize,
780        }
781
782        impl ResponseWriter for ReallocationWriter {
783            fn write(&mut self, content: &str) -> webui_handler::Result<()> {
784                let previous_capacity = self.output.capacity();
785                self.output.push_str(content);
786                self.reallocations += usize::from(self.output.capacity() != previous_capacity);
787                Ok(())
788            }
789
790            fn end(&mut self) -> webui_handler::Result<()> {
791                Ok(())
792            }
793        }
794
795        fn render_with_capacity(protocol: &HandlerProtocol, capacity: usize) -> (String, usize) {
796            let mut writer = ReallocationWriter {
797                output: String::with_capacity(capacity),
798                reallocations: 0,
799            };
800            WebUIHandler::new()
801                .render(
802                    protocol,
803                    &Value::Null,
804                    &RenderOptions::new("index.html", "/"),
805                    &mut writer,
806                )
807                .expect("render should succeed");
808            (writer.output, writer.reallocations)
809        }
810
811        let source = format!("<main>{}</main>", "<p>capacity</p>".repeat(1024));
812        let protocol =
813            HandlerProtocol::from_protobuf(&build_protocol(&source)).expect("protocol should load");
814        let (cold_output, cold_reallocations) = render_with_capacity(
815            &protocol,
816            RenderCapacityHints::new().load("index.html", "/"),
817        );
818        let (warm_output, warm_reallocations) = render_with_capacity(&protocol, cold_output.len());
819
820        assert!(cold_reallocations > 0);
821        assert_eq!(warm_reallocations, 0);
822        assert_eq!(warm_output, cold_output);
823    }
824
825    #[test]
826    fn test_simple_passthrough() {
827        let proto = build_protocol("<p>Hello</p>");
828        let result = render_to_string(&proto, "{}");
829        assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
830    }
831
832    #[test]
833    fn test_signal_substitution() {
834        let proto = build_protocol("Hello, {{name}}!");
835        let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
836        assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
837    }
838
839    #[test]
840    fn protocol_reuses_decoded_protocol_for_json_state() {
841        let proto = build_protocol("Hello, {{name}}!");
842        let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
843
844        let first = protocol
845            .render(
846                r#"{"name":"First"}"#.to_string(),
847                "index.html".to_string(),
848                "/".to_string(),
849            )
850            .expect("first render should succeed");
851        let second = protocol
852            .render(
853                r#"{"name":"世界"}"#.to_string(),
854                "index.html".to_string(),
855                "/".to_string(),
856            )
857            .expect("second render should succeed");
858
859        assert_eq!(first.as_ref(), b"Hello, First!");
860        assert_eq!(second.as_ref(), "Hello, 世界!".as_bytes());
861    }
862
863    #[test]
864    fn streaming_steps_preserve_key_types_and_checkpoint_segments() {
865        let proto = build_protocol(concat!(
866            "<html><head></head><body>",
867            r#"<boundary name="first" key="{{firstId}}"><p>{{firstLabel}}</p></boundary>"#,
868            "<span>between</span>",
869            r#"<boundary name="second" key="{{secondId}}"><p>{{secondLabel}}</p></boundary>"#,
870            "<footer>tail</footer>",
871            "</body></html>",
872        ));
873        let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
874        let mut session = protocol
875            .stream_response("index.html".to_string(), "/".to_string(), None)
876            .expect("session should open");
877        let state = r#"{"firstId":"alpha","firstLabel":"a","secondId":20,"secondLabel":"b"}"#;
878
879        let first = session
880            .start(state.to_string())
881            .expect("start should discover first boundary");
882        assert!(!first.done);
883        assert!(!first.bytes.is_empty());
884        let first_boundary = first.boundary.expect("first boundary should be returned");
885        assert_eq!(first_boundary.instance_id, 0);
886        assert_eq!(first_boundary.declaration_id, 0);
887        assert_eq!(first_boundary.owner, "index.html");
888        assert_eq!(first_boundary.name, "first");
889        assert!(matches!(
890            first_boundary.key,
891            Some(Either::A(value)) if value == "alpha"
892        ));
893
894        let resumed = session
895            .resume(
896                first_boundary.instance_id,
897                state.to_string(),
898                Some("final".to_string()),
899            )
900            .expect("resume should commit first boundary");
901        assert!(!resumed.done);
902        assert!(resumed.boundary.is_none());
903        let resumed_text =
904            std::str::from_utf8(&resumed.bytes).expect("resume output should be UTF-8");
905        assert!(resumed_text.contains(">a<"));
906        assert!(!resumed_text.contains("between"));
907
908        let next = session
909            .advance()
910            .expect("advance should discover second boundary");
911        assert!(!next.done);
912        let next_text = std::str::from_utf8(&next.bytes).expect("advance output should be UTF-8");
913        assert!(next_text.contains("between"));
914        assert!(!next_text.contains(">b<"));
915        let second_boundary = next.boundary.expect("second boundary should be returned");
916        assert_eq!(second_boundary.instance_id, 1);
917        assert_eq!(second_boundary.declaration_id, 1);
918        assert_eq!(second_boundary.name, "second");
919        assert!(matches!(
920            second_boundary.key,
921            Some(Either::B(value)) if value == 20.0
922        ));
923
924        let resumed = session
925            .resume(second_boundary.instance_id, state.to_string(), None)
926            .expect("resume should commit second boundary");
927        assert!(!resumed.done);
928        assert!(resumed.boundary.is_none());
929        let resumed_text =
930            std::str::from_utf8(&resumed.bytes).expect("resume output should be UTF-8");
931        assert!(resumed_text.contains(">b<"));
932        assert!(!resumed_text.contains("tail"));
933
934        let done = session.advance().expect("final advance should complete");
935        assert!(done.done);
936        assert!(done.boundary.is_none());
937        assert!(std::str::from_utf8(&done.bytes)
938            .expect("advance output should be UTF-8")
939            .contains("tail"));
940    }
941
942    #[test]
943    fn streaming_update_returns_buffer_for_updatable_occurrence() {
944        let proto = build_protocol(concat!(
945            "<html><head></head><body>",
946            r#"<boundary name="first"><p>{{count}}</p></boundary>"#,
947            r#"<boundary name="second"><p>done</p></boundary>"#,
948            "</body></html>",
949        ));
950        let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
951        let mut session = protocol
952            .stream_response("index.html".to_string(), "/".to_string(), None)
953            .expect("session should open");
954        let first = session
955            .start(r#"{"count":1}"#.to_string())
956            .expect("start should discover first boundary")
957            .boundary
958            .expect("first boundary should be returned");
959        let resumed = session
960            .resume(
961                first.instance_id,
962                r#"{"count":1}"#.to_string(),
963                Some("updatable".to_string()),
964            )
965            .expect("resume should commit updatable boundary");
966        assert!(!resumed.done);
967        assert!(resumed.boundary.is_none());
968
969        let update = session
970            .update(first.instance_id, r#"{"count":2}"#.to_string())
971            .expect("update should render");
972        assert!(!update.is_empty());
973        let update_text = std::str::from_utf8(&update).expect("update should be UTF-8");
974        assert!(update_text.contains(r#""count":2"#));
975
976        let second = session
977            .advance()
978            .expect("advance should discover second boundary")
979            .boundary
980            .expect("second boundary should be returned");
981        let resumed = session
982            .resume(second.instance_id, "{}".to_string(), None)
983            .expect("second resume should commit boundary");
984        assert!(!resumed.done);
985        assert!(resumed.boundary.is_none());
986        let done = session.advance().expect("final advance should complete");
987        assert!(done.done);
988    }
989
990    #[test]
991    fn streaming_advance_rejects_out_of_order_calls() {
992        let proto = build_protocol(concat!(
993            "<html><head></head><body>",
994            r#"<boundary name="first"><p>first</p></boundary>"#,
995            "</body></html>",
996        ));
997        let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
998        let mut session = protocol
999            .stream_response("index.html".to_string(), "/".to_string(), None)
1000            .expect("session should open");
1001
1002        let before_start = session
1003            .advance()
1004            .err()
1005            .expect("advance before start should fail");
1006        assert!(before_start
1007            .to_string()
1008            .contains("start must be called before this operation"));
1009
1010        let start = session
1011            .start("{}".to_string())
1012            .expect("start should succeed");
1013        let before_resume = session
1014            .advance()
1015            .err()
1016            .expect("advance before resume should fail");
1017        assert!(before_resume
1018            .to_string()
1019            .contains("there is no committed boundary to advance past"));
1020
1021        let boundary = start.boundary.expect("first boundary should be returned");
1022        session
1023            .resume(boundary.instance_id, "{}".to_string(), None)
1024            .expect("resume should still succeed after rejected advance");
1025        assert!(session.advance().expect("advance should complete").done);
1026    }
1027
1028    #[test]
1029    fn streaming_start_returns_done_for_boundary_free_document() {
1030        let proto = build_protocol("<html><head></head><body><p>done</p></body></html>");
1031        let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
1032        let mut session = protocol
1033            .stream_response("index.html".to_string(), "/".to_string(), None)
1034            .expect("session should open");
1035
1036        let step = session
1037            .start("{}".to_string())
1038            .expect("boundary-free start should complete");
1039        assert!(step.done);
1040        assert!(step.boundary.is_none());
1041        assert!(std::str::from_utf8(&step.bytes)
1042            .expect("output should be UTF-8")
1043            .contains("<p>done</p>"));
1044    }
1045
1046    #[test]
1047    fn test_for_loop() {
1048        let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
1049        let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
1050        assert_eq!(
1051            result.as_deref(),
1052            Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
1053        );
1054    }
1055
1056    #[test]
1057    fn test_if_condition_true() {
1058        let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
1059        let result = render_to_string(&proto, r#"{"show": true}"#);
1060        assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
1061    }
1062
1063    #[test]
1064    fn test_if_condition_false() {
1065        let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
1066        let result = render_to_string(&proto, r#"{"show": false}"#);
1067        assert_eq!(result.as_deref(), Ok(""));
1068    }
1069
1070    #[test]
1071    fn test_html_escaping() {
1072        let proto = build_protocol("<div>{{content}}</div>");
1073        let state = r#"{"content": "<script>alert('xss')</script>"}"#;
1074        let result = render_to_string(&proto, state).expect("render should succeed");
1075        assert!(!result.contains("<script>"));
1076        assert!(result.contains("&lt;script&gt;"));
1077    }
1078
1079    #[test]
1080    fn test_raw_signal() {
1081        let proto = build_protocol("<div>{{{content}}}</div>");
1082        let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
1083        assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
1084    }
1085
1086    #[test]
1087    fn test_invalid_json() {
1088        let proto = build_protocol("<p>hi</p>");
1089        let result = render_to_string(&proto, "NOT JSON");
1090        assert!(result.is_err());
1091    }
1092
1093    #[test]
1094    fn test_empty_state() {
1095        let proto = build_protocol("<p>static</p>");
1096        let result = render_to_string(&proto, "{}");
1097        assert_eq!(result.as_deref(), Ok("<p>static</p>"));
1098    }
1099
1100    #[test]
1101    fn test_nested_object_signal() {
1102        let proto = build_protocol("{{user.name}}");
1103        let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
1104        assert_eq!(result.as_deref(), Ok("Alice"));
1105    }
1106
1107    #[test]
1108    fn test_invalid_protobuf() {
1109        let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
1110        assert!(result.is_err());
1111    }
1112
1113    /// Parse `html`, attach a sorted hydration `schema`, and encode to protobuf.
1114    fn build_projected_protocol(html: &str, schema: &[&str]) -> Vec<u8> {
1115        let mut parser = HtmlParser::new();
1116        parser.parse("index.html", html).expect("parse failed");
1117        let tokens = parser.take_tokens();
1118        let mut protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
1119        protocol.fragments.insert(
1120            "client-card".to_string(),
1121            webui_protocol::FragmentList {
1122                fragments: vec![webui_protocol::WebUIFragment::raw("<p>client</p>")],
1123                contains_boundary: false,
1124            },
1125        );
1126        protocol
1127            .fragments
1128            .get_mut("index.html")
1129            .expect("index fragment should exist")
1130            .fragments
1131            .insert(1, webui_protocol::WebUIFragment::component("client-card"));
1132        protocol.initial_state_strategy = webui_protocol::InitialStateStrategy::Components as i32;
1133        protocol.components.insert(
1134            "client-card".to_string(),
1135            webui_protocol::ComponentData {
1136                hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
1137                hydration_keys: schema.iter().map(|key| (*key).to_string()).collect(),
1138                ..Default::default()
1139            },
1140        );
1141        protocol.to_protobuf().expect("protobuf encode failed")
1142    }
1143
1144    /// Render protocol bytes with the WebUI hydration plugin so the `#webui-data`
1145    /// bootstrap block (and its projected state) is emitted — this mirrors the
1146    /// production `render(..., plugin = "webui")` path.
1147    fn render_with_webui_plugin(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
1148        let protocol = HandlerProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
1149        let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
1150
1151        let mut output = String::with_capacity(1024);
1152        struct StringWriter<'a> {
1153            output: &'a mut String,
1154        }
1155        impl ResponseWriter for StringWriter<'_> {
1156            fn write(&mut self, content: &str) -> webui_handler::Result<()> {
1157                self.output.push_str(content);
1158                Ok(())
1159            }
1160            fn end(&mut self) -> webui_handler::Result<()> {
1161                Ok(())
1162            }
1163        }
1164        let mut writer = StringWriter {
1165            output: &mut output,
1166        };
1167        let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1168        handler
1169            .render(
1170                &protocol,
1171                &state,
1172                &RenderOptions::new("index.html", "/"),
1173                &mut writer,
1174            )
1175            .map_err(|e| e.to_string())?;
1176        Ok(output)
1177    }
1178
1179    #[test]
1180    fn render_projects_state_to_component_hydration_keys() {
1181        // Full document so the parser emits a `body_end` signal, which makes the
1182        // WebUI plugin emit the #webui-data bootstrap block.
1183        let bytes =
1184            build_projected_protocol("<html><body><p>{{kept}}</p></body></html>", &["kept"]);
1185        let out =
1186            render_with_webui_plugin(&bytes, r#"{"kept":"KEPT_VALUE","dropped":"DROPPED_VALUE"}"#)
1187                .expect("render should succeed");
1188
1189        // Only the hydratable key reaches the bootstrap state block...
1190        assert!(
1191            out.contains(r#""kept":"KEPT_VALUE""#),
1192            "hydratable key missing from bootstrap state: {out}"
1193        );
1194        // ...the non-hydratable key is projected out entirely.
1195        assert!(
1196            !out.contains("DROPPED_VALUE"),
1197            "server-only value leaked: {out}"
1198        );
1199        assert!(
1200            !out.contains("dropped"),
1201            "server-only key name leaked: {out}"
1202        );
1203    }
1204
1205    // ── Tests for build() and inspect() napi exports ─────────────────
1206
1207    fn projection_manifest_json() -> String {
1208        const EMPTY_SHA256: &str =
1209            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1210        let mut manifest = ProjectionManifest {
1211            schema: SCHEMA_ID.to_string(),
1212            producer: ProjectionProducer {
1213                name: PRODUCER_NAME.to_string(),
1214                version: "0.0.18".to_string(),
1215            },
1216            adapter: ProjectionAdapter {
1217                name: "test".to_string(),
1218                bundler: "test@1.0.0".to_string(),
1219            },
1220            root: ".".to_string(),
1221            analysis_hash: format!("sha256:{}", "1".repeat(64)),
1222            build_id: String::new(),
1223            inputs: BTreeMap::from([("demo-card.ts".to_string(), EMPTY_SHA256.to_string())]),
1224            outputs: BTreeMap::from([("bundle.js".to_string(), EMPTY_SHA256.to_string())]),
1225            components: BTreeMap::from([(
1226                "demo-card".to_string(),
1227                ProjectionComponent {
1228                    module: "demo-card.ts".to_string(),
1229                    outputs: vec!["bundle.js".to_string()],
1230                    hydration_keys: vec!["name".to_string()],
1231                    navigation_keys: vec!["label".to_string(), "name".to_string()],
1232                },
1233            )]),
1234            entry_closures: BTreeMap::new(),
1235        };
1236        manifest.build_id = manifest.compute_build_id();
1237        serde_json::to_string(&manifest).unwrap()
1238    }
1239
1240    fn projection_build_options(app_dir: &std::path::Path) -> JsBuildOptions {
1241        JsBuildOptions {
1242            app_dir: app_dir.to_string_lossy().to_string(),
1243            entry: None,
1244            css: None,
1245            dom: None,
1246            css_bundle: None,
1247            plugin: Some("webui".to_string()),
1248            components: None,
1249            component_asset_roots: None,
1250            metafile: None,
1251            css_file_name_template: None,
1252            css_public_base: None,
1253            legal_comments: None,
1254            theme: None,
1255            projection_manifests: None,
1256            projection_manifest_objects: None,
1257        }
1258    }
1259
1260    #[test]
1261    fn test_build_accepts_projection_paths_and_inline_objects() {
1262        let dir = tempfile::TempDir::new().unwrap();
1263        std::fs::write(dir.path().join("index.html"), "<demo-card></demo-card>").unwrap();
1264        std::fs::write(dir.path().join("demo-card.html"), "<p>{{name}}</p>").unwrap();
1265        std::fs::write(dir.path().join("demo-card.ts"), "").unwrap();
1266        std::fs::write(dir.path().join("bundle.js"), "").unwrap();
1267        let manifest_path = dir.path().join("projection.json");
1268        let json = projection_manifest_json();
1269        std::fs::write(&manifest_path, &json).unwrap();
1270
1271        let mut path_options = projection_build_options(dir.path());
1272        path_options.projection_manifests = Some(vec![manifest_path.to_string_lossy().to_string()]);
1273        let path_result = build(path_options).unwrap();
1274        let path_protocol = WebUIProtocol::from_protobuf(&path_result.protocol).unwrap();
1275        assert_eq!(
1276            path_protocol.components["demo-card"].hydration_keys,
1277            ["name"]
1278        );
1279
1280        std::fs::remove_file(&manifest_path).unwrap();
1281        let mut inline_options = projection_build_options(dir.path());
1282        inline_options.projection_manifest_objects = Some(vec![JsProjectionManifest {
1283            path: manifest_path.to_string_lossy().to_string(),
1284            json,
1285        }]);
1286        let inline_result = build(inline_options).unwrap();
1287        let inline_protocol = WebUIProtocol::from_protobuf(&inline_result.protocol).unwrap();
1288        assert_eq!(
1289            inline_protocol.components["demo-card"].navigation_keys,
1290            ["label", "name"]
1291        );
1292    }
1293
1294    #[test]
1295    fn test_build_simple_app() {
1296        let dir = tempfile::TempDir::new().unwrap();
1297        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1298
1299        let options = JsBuildOptions {
1300            app_dir: dir.path().to_string_lossy().to_string(),
1301            entry: None,
1302            css: None,
1303            dom: None,
1304            css_bundle: None,
1305            plugin: None,
1306            components: None,
1307            component_asset_roots: None,
1308            metafile: None,
1309            css_file_name_template: None,
1310            css_public_base: None,
1311            legal_comments: None,
1312            theme: None,
1313            projection_manifests: None,
1314            projection_manifest_objects: None,
1315        };
1316
1317        let result = build(options).unwrap();
1318        assert!(!result.protocol.is_empty());
1319        assert!(result.stats.fragment_count > 0);
1320        assert!(result.stats.protocol_size_bytes > 0);
1321        assert!(result.stats.duration_ms >= 0.0);
1322    }
1323
1324    #[test]
1325    fn test_build_with_custom_entry() {
1326        let dir = tempfile::TempDir::new().unwrap();
1327        std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
1328
1329        let options = JsBuildOptions {
1330            app_dir: dir.path().to_string_lossy().to_string(),
1331            entry: Some("page.html".to_string()),
1332            css: None,
1333            dom: None,
1334            css_bundle: None,
1335            plugin: None,
1336            components: None,
1337            component_asset_roots: None,
1338            metafile: None,
1339            css_file_name_template: None,
1340            css_public_base: None,
1341            legal_comments: None,
1342            theme: None,
1343            projection_manifests: None,
1344            projection_manifest_objects: None,
1345        };
1346
1347        let result = build(options).unwrap();
1348        assert!(!result.protocol.is_empty());
1349    }
1350
1351    #[test]
1352    fn test_build_missing_app_dir() {
1353        let options = JsBuildOptions {
1354            app_dir: "/nonexistent/path".to_string(),
1355            entry: None,
1356            css: None,
1357            dom: None,
1358            css_bundle: None,
1359            plugin: None,
1360            components: None,
1361            component_asset_roots: None,
1362            metafile: None,
1363            css_file_name_template: None,
1364            css_public_base: None,
1365            legal_comments: None,
1366            theme: None,
1367            projection_manifests: None,
1368            projection_manifest_objects: None,
1369        };
1370
1371        let result = build(options);
1372        assert!(result.is_err());
1373    }
1374
1375    #[test]
1376    fn test_build_invalid_css() {
1377        let dir = tempfile::TempDir::new().unwrap();
1378        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1379
1380        let options = JsBuildOptions {
1381            app_dir: dir.path().to_string_lossy().to_string(),
1382            entry: None,
1383            css: Some("bogus".to_string()),
1384            dom: None,
1385            css_bundle: None,
1386            plugin: None,
1387            components: None,
1388            component_asset_roots: None,
1389            metafile: None,
1390            css_file_name_template: None,
1391            css_public_base: None,
1392            legal_comments: None,
1393            theme: None,
1394            projection_manifests: None,
1395            projection_manifest_objects: None,
1396        };
1397
1398        let result = build(options);
1399        assert!(result.is_err());
1400    }
1401
1402    #[test]
1403    fn test_build_invalid_dom_strategy() {
1404        let dir = tempfile::TempDir::new().unwrap();
1405        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1406
1407        let options = JsBuildOptions {
1408            app_dir: dir.path().to_string_lossy().to_string(),
1409            entry: None,
1410            css: None,
1411            dom: Some("closed".to_string()),
1412            css_bundle: None,
1413            plugin: None,
1414            components: None,
1415            component_asset_roots: None,
1416            metafile: None,
1417            css_file_name_template: None,
1418            css_public_base: None,
1419            legal_comments: None,
1420            theme: None,
1421            projection_manifests: None,
1422            projection_manifest_objects: None,
1423        };
1424
1425        assert!(build(options).is_err());
1426    }
1427
1428    #[test]
1429    fn test_build_with_components_css() {
1430        let dir = tempfile::TempDir::new().unwrap();
1431        std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
1432        std::fs::write(dir.path().join("my-card.html"), "<div>content</div>").unwrap();
1433        std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
1434
1435        let options = JsBuildOptions {
1436            app_dir: dir.path().to_string_lossy().to_string(),
1437            entry: None,
1438            css: Some("link".to_string()),
1439            dom: None,
1440            css_bundle: None,
1441            plugin: None,
1442            components: None,
1443            component_asset_roots: None,
1444            metafile: None,
1445            css_file_name_template: None,
1446            css_public_base: None,
1447            legal_comments: None,
1448            theme: None,
1449            projection_manifests: None,
1450            projection_manifest_objects: None,
1451        };
1452
1453        let result = build(options).unwrap();
1454        // css_files is flattened: [filename, content, filename, content, ...]
1455        assert_eq!(result.css_files.len(), 2);
1456        assert_eq!(result.css_files[0], "my-card.css");
1457        assert!(result.css_files[1].contains("color: red"));
1458        assert_eq!(result.stats.css_file_count, 1);
1459    }
1460
1461    #[test]
1462    fn test_build_with_theme_missing_token_errors() {
1463        let dir = tempfile::TempDir::new().unwrap();
1464        std::fs::write(dir.path().join("index.html"), "<my-card></my-card>").unwrap();
1465        std::fs::write(dir.path().join("my-card.html"), "<div>Card</div>").unwrap();
1466        std::fs::write(
1467            dir.path().join("my-card.css"),
1468            ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
1469        )
1470        .unwrap();
1471        let theme_path = dir.path().join("theme.json");
1472        std::fs::write(&theme_path, r#"{"themes":{"light":{"token-b":"green"}}}"#).unwrap();
1473
1474        let options = JsBuildOptions {
1475            app_dir: dir.path().to_string_lossy().to_string(),
1476            entry: None,
1477            css: Some("link".to_string()),
1478            dom: None,
1479            css_bundle: None,
1480            plugin: None,
1481            components: None,
1482            component_asset_roots: None,
1483            metafile: None,
1484            css_file_name_template: None,
1485            css_public_base: None,
1486            legal_comments: None,
1487            theme: Some(theme_path.to_string_lossy().to_string()),
1488            projection_manifests: None,
1489            projection_manifest_objects: None,
1490        };
1491
1492        let Err(err) = build(options) else {
1493            panic!("missing theme token must fail");
1494        };
1495        let message = err.to_string();
1496        assert!(message.contains("missing-theme-token"), "msg: {message}");
1497        assert!(message.contains("--token-c"), "msg: {message}");
1498    }
1499
1500    #[test]
1501    fn test_build_returns_component_asset_files() {
1502        let dir = tempfile::TempDir::new().unwrap();
1503        std::fs::write(dir.path().join("index.html"), "<app-shell></app-shell>").unwrap();
1504        std::fs::write(dir.path().join("app-shell.html"), "<div></div>").unwrap();
1505        std::fs::write(dir.path().join("lazy-panel.html"), "<p>{{title}}</p>").unwrap();
1506        std::fs::write(dir.path().join("lazy-panel.ts"), "export {};").unwrap();
1507
1508        let options = JsBuildOptions {
1509            app_dir: dir.path().to_string_lossy().to_string(),
1510            entry: None,
1511            css: Some("link".to_string()),
1512            dom: None,
1513            css_bundle: None,
1514            plugin: Some("webui".to_string()),
1515            components: None,
1516            component_asset_roots: Some(vec!["lazy-panel".to_string()]),
1517            metafile: Some(true),
1518            css_file_name_template: None,
1519            css_public_base: None,
1520            legal_comments: None,
1521            theme: None,
1522            projection_manifests: None,
1523            projection_manifest_objects: None,
1524        };
1525
1526        let result = build(options).unwrap();
1527
1528        assert_eq!(result.component_asset_files.len(), 4);
1529        assert_eq!(result.component_asset_files[0], "lazy-panel.webui.js");
1530        assert!(result.component_asset_files[1].contains("webui-component-asset"));
1531        assert!(result.component_asset_files[1].contains("export default asset;"));
1532        assert!(!result.component_asset_files[1].contains("defineComponentAsset"));
1533        assert!(result.component_asset_files[2].starts_with("components/lazy-panel."));
1534        assert!(result.component_asset_files[2].ends_with(".webui.js"));
1535        assert!(result.component_asset_files[3].contains("export default asset;"));
1536        let metafile = result
1537            .metafile
1538            .as_deref()
1539            .expect("requested metafile must be returned");
1540        let parsed: serde_json::Value = serde_json::from_str(metafile).unwrap();
1541        assert!(parsed["inputs"].get("webui:component/lazy-panel").is_some());
1542        assert!(parsed["outputs"].get("lazy-panel.webui.js").is_some());
1543    }
1544
1545    #[test]
1546    fn test_build_legal_comments_none_strips_legal_css() {
1547        let dir = tempfile::TempDir::new().unwrap();
1548        std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
1549        std::fs::write(
1550            dir.path().join("my-card.html"),
1551            r#"<template shadowrootmode="open"><div>content</div></template>"#,
1552        )
1553        .unwrap();
1554        std::fs::write(
1555            dir.path().join("my-card.css"),
1556            "/*! @license MIT */ .card { color: red; }",
1557        )
1558        .unwrap();
1559
1560        let options = JsBuildOptions {
1561            app_dir: dir.path().to_string_lossy().to_string(),
1562            entry: None,
1563            css: Some("link".to_string()),
1564            dom: None,
1565            css_bundle: None,
1566            plugin: None,
1567            components: None,
1568            component_asset_roots: None,
1569            metafile: None,
1570            css_file_name_template: None,
1571            css_public_base: None,
1572            legal_comments: Some("none".to_string()),
1573            theme: None,
1574            projection_manifests: None,
1575            projection_manifest_objects: None,
1576        };
1577
1578        let result = build(options).unwrap();
1579        assert_eq!(result.css_files[1], " .card { color: red; }");
1580    }
1581
1582    #[test]
1583    fn test_build_invalid_legal_comments() {
1584        let dir = tempfile::TempDir::new().unwrap();
1585        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1586
1587        let options = JsBuildOptions {
1588            app_dir: dir.path().to_string_lossy().to_string(),
1589            entry: None,
1590            css: None,
1591            dom: None,
1592            css_bundle: None,
1593            plugin: None,
1594            components: None,
1595            component_asset_roots: None,
1596            metafile: None,
1597            css_file_name_template: None,
1598            css_public_base: None,
1599            legal_comments: Some("linked".to_string()),
1600            theme: None,
1601            projection_manifests: None,
1602            projection_manifest_objects: None,
1603        };
1604
1605        let result = build(options);
1606        assert!(result.is_err());
1607    }
1608
1609    #[test]
1610    fn test_build_with_unwrapped_component_defaults_to_shadow() {
1611        let dir = tempfile::TempDir::new().unwrap();
1612        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1613        std::fs::write(dir.path().join("my-card.html"), "<div>content</div>").unwrap();
1614
1615        let options = JsBuildOptions {
1616            app_dir: dir.path().to_string_lossy().to_string(),
1617            entry: None,
1618            css: None,
1619            dom: None,
1620            css_bundle: None,
1621            plugin: None,
1622            components: None,
1623            component_asset_roots: None,
1624            metafile: None,
1625            css_file_name_template: None,
1626            css_public_base: None,
1627            legal_comments: None,
1628            theme: None,
1629            projection_manifests: None,
1630            projection_manifest_objects: None,
1631        };
1632
1633        let result = build(options).unwrap();
1634        let json = inspect(result.protocol).unwrap();
1635        assert!(json.contains("shadowrootmode"));
1636    }
1637
1638    #[test]
1639    fn test_build_with_light_dom_omits_generated_shadow_root() {
1640        let dir = tempfile::TempDir::new().unwrap();
1641        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1642        std::fs::write(dir.path().join("my-card.html"), "<div>content</div>").unwrap();
1643
1644        let options = JsBuildOptions {
1645            app_dir: dir.path().to_string_lossy().to_string(),
1646            entry: None,
1647            css: None,
1648            dom: Some("light".to_string()),
1649            css_bundle: None,
1650            plugin: None,
1651            components: None,
1652            component_asset_roots: None,
1653            metafile: None,
1654            css_file_name_template: None,
1655            css_public_base: None,
1656            legal_comments: None,
1657            theme: None,
1658            projection_manifests: None,
1659            projection_manifest_objects: None,
1660        };
1661
1662        let result = build(options).unwrap();
1663        let json = inspect(result.protocol).unwrap();
1664        assert!(!json.contains("shadowrootmode"));
1665    }
1666
1667    #[test]
1668    fn test_build_with_authored_shadow_dom_preserves_shadow_root_template() {
1669        let dir = tempfile::TempDir::new().unwrap();
1670        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1671        std::fs::write(
1672            dir.path().join("my-card.html"),
1673            r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
1674        )
1675        .unwrap();
1676
1677        let options = JsBuildOptions {
1678            app_dir: dir.path().to_string_lossy().to_string(),
1679            entry: None,
1680            css: None,
1681            dom: None,
1682            css_bundle: None,
1683            plugin: None,
1684            components: None,
1685            component_asset_roots: None,
1686            metafile: None,
1687            css_file_name_template: None,
1688            css_public_base: None,
1689            legal_comments: None,
1690            theme: None,
1691            projection_manifests: None,
1692            projection_manifest_objects: None,
1693        };
1694
1695        let result = build(options).unwrap();
1696        let json = inspect(result.protocol).unwrap();
1697        assert!(
1698            json.contains("shadowrootmode"),
1699            "authored Shadow DOM wrapper should be preserved, got: {json}"
1700        );
1701    }
1702
1703    #[test]
1704    fn test_build_rejects_invalid_shadow_wrapper() {
1705        let dir = tempfile::TempDir::new().unwrap();
1706        std::fs::write(dir.path().join("index.html"), "<my-card></my-card>").unwrap();
1707        std::fs::write(
1708            dir.path().join("my-card.html"),
1709            r#"<template shadowrootmode="closed"><p>invalid</p></template>"#,
1710        )
1711        .unwrap();
1712
1713        let options = JsBuildOptions {
1714            app_dir: dir.path().to_string_lossy().to_string(),
1715            entry: None,
1716            css: None,
1717            dom: None,
1718            css_bundle: None,
1719            plugin: None,
1720            components: None,
1721            component_asset_roots: None,
1722            metafile: None,
1723            css_file_name_template: None,
1724            css_public_base: None,
1725            legal_comments: None,
1726            theme: None,
1727            projection_manifests: None,
1728            projection_manifest_objects: None,
1729        };
1730
1731        let result = build(options);
1732        assert!(result.is_err());
1733    }
1734
1735    #[test]
1736    fn test_inspect_valid_protocol() {
1737        let proto = build_protocol("<h1>Hello {{name}}</h1>");
1738        let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
1739        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1740        assert!(parsed.get("fragments").is_some());
1741    }
1742
1743    #[test]
1744    fn test_inspect_invalid_protocol() {
1745        let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
1746        assert!(result.is_err());
1747    }
1748
1749    #[test]
1750    fn test_protocol_tokens_empty() {
1751        let proto = build_protocol("<p>Hello</p>");
1752        let protocol = Protocol::new(Buffer::from(proto), None).unwrap();
1753        let tokens = protocol.tokens();
1754        assert!(tokens.is_empty());
1755    }
1756
1757    #[test]
1758    fn test_protocol_tokens_returns_parsed_tokens() {
1759        // Build from a protocol that has CSS tokens via with_tokens constructor.
1760        let mut parser = HtmlParser::new();
1761        parser.parse("index.html", "<p>Hi</p>").expect("parse");
1762        let protocol = WebUIProtocol::with_tokens(
1763            parser.into_fragment_records(),
1764            vec![
1765                "colorBrandBackground".to_string(),
1766                "fontSizeBase300".to_string(),
1767            ],
1768        );
1769        let proto = protocol.to_protobuf().expect("encode");
1770        let protocol = Protocol::new(Buffer::from(proto), None).unwrap();
1771        let tokens = protocol.tokens();
1772        assert_eq!(tokens, vec!["colorBrandBackground", "fontSizeBase300"]);
1773    }
1774
1775    #[test]
1776    fn test_protocol_tokens_invalid_protobuf() {
1777        let result = Protocol::new(Buffer::from(vec![0xFF, 0xFF]), None);
1778        assert!(result.is_err());
1779    }
1780}