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