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