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