1use napi::bindgen_prelude::{Buffer, Function};
32use napi::Error as NapiError;
33use napi_derive::napi;
34use serde_json::Value;
35use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
36use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
37use webui_handler::plugin::webui::WebUIHydrationPlugin;
38use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
39use webui_protocol::WebUIProtocol;
40
41#[napi(object)]
43pub struct JsBuildStats {
44 pub duration_ms: f64,
46 pub fragment_count: u32,
48 pub component_count: u32,
50 pub css_file_count: u32,
52 pub protocol_size_bytes: u32,
54 pub token_count: u32,
56}
57
58#[napi(object)]
60pub struct JsBuildResult {
61 pub protocol: Buffer,
63 pub css_files: Vec<String>,
65 pub stats: JsBuildStats,
67}
68
69#[napi(object)]
71pub struct JsBuildOptions {
72 pub app_dir: String,
74 pub entry: Option<String>,
76 pub css: Option<String>,
78 pub dom: Option<String>,
80 pub plugin: Option<String>,
82 pub components: Option<Vec<String>>,
84 pub css_file_name_template: Option<String>,
86 pub css_public_base: Option<String>,
88 pub legal_comments: Option<String>,
90}
91
92#[napi]
96#[allow(clippy::cast_possible_truncation)] pub fn build(options: JsBuildOptions) -> napi::Result<JsBuildResult> {
98 let css = options
99 .css
100 .map(|s| s.parse::<webui::CssStrategy>())
101 .transpose()
102 .map_err(NapiError::from_reason)?
103 .unwrap_or_default();
104
105 let dom = options
106 .dom
107 .map(|s| s.parse::<webui::DomStrategy>())
108 .transpose()
109 .map_err(NapiError::from_reason)?
110 .unwrap_or_default();
111
112 let plugin = options
113 .plugin
114 .map(|s| s.parse::<webui::Plugin>())
115 .transpose()
116 .map_err(NapiError::from_reason)?;
117
118 let legal_comments = options
119 .legal_comments
120 .map(|s| s.parse::<webui::LegalComments>())
121 .transpose()
122 .map_err(NapiError::from_reason)?
123 .unwrap_or_default();
124
125 let build_options = webui::BuildOptions {
126 app_dir: std::path::PathBuf::from(&options.app_dir),
127 entry: options.entry.unwrap_or_else(|| "index.html".to_string()),
128 css,
129 dom,
130 plugin,
131 components: options.components.unwrap_or_default(),
132 css_file_name_template: options
133 .css_file_name_template
134 .unwrap_or_else(|| webui::DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string()),
135 css_public_base: options.css_public_base,
136 legal_comments,
137 };
138
139 let result = webui::build(build_options)
140 .map_err(|e| NapiError::from_reason(format!("Build error: {}", e.chain_message())))?;
141
142 let css_files: Vec<String> = result
144 .css_files
145 .into_iter()
146 .flat_map(|(name, content)| [name, content])
147 .collect();
148
149 Ok(JsBuildResult {
150 protocol: Buffer::from(result.protocol_bytes),
151 css_files,
152 stats: JsBuildStats {
153 duration_ms: result.stats.duration.as_secs_f64() * 1000.0,
154 fragment_count: result.stats.fragment_count as u32,
155 component_count: result.stats.component_count as u32,
156 css_file_count: result.stats.css_file_count as u32,
157 protocol_size_bytes: result.stats.protocol_size_bytes as u32,
158 token_count: result.stats.token_count as u32,
159 },
160 })
161}
162
163#[napi]
165pub fn inspect(protocol_data: Buffer) -> napi::Result<String> {
166 webui::inspect_bytes(&protocol_data)
167 .map_err(|e| NapiError::from_reason(format!("Inspect error: {e}")))
168}
169
170#[napi]
178pub fn render_partial(
179 protocol_data: Buffer,
180 state_json: String,
181 entry_id: String,
182 request_path: String,
183 inventory_hex: String,
184) -> napi::Result<String> {
185 let protocol = WebUIProtocol::from_protobuf(&protocol_data)
186 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
187
188 let state: serde_json::Value = serde_json::from_str(&state_json)
189 .map_err(|e| NapiError::from_reason(format!("invalid state JSON: {e}")))?;
190
191 let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
196
197 let mut result = webui_handler::route_handler::render_partial(
198 &protocol,
199 &entry_id,
200 &request_path,
201 &inventory_hex,
202 &mut index,
203 )
204 .map_err(|e| NapiError::from_reason(format!("render_partial failed: {e}")))?;
205 if let Some(obj) = result.as_object_mut() {
206 obj.insert("state".into(), state);
207 }
208
209 serde_json::to_string(&result)
210 .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
211}
212
213#[napi]
214pub fn render_component_templates(
215 protocol_data: Buffer,
216 component_tags_json: String,
217 inventory_hex: String,
218) -> napi::Result<String> {
219 let protocol = WebUIProtocol::from_protobuf(&protocol_data)
220 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
221
222 let tags: Vec<String> = serde_json::from_str(&component_tags_json)
223 .map_err(|e| NapiError::from_reason(format!("invalid tags JSON: {e}")))?;
224 let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
225
226 let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
228
229 let result = webui_handler::route_handler::render_component_templates(
230 &protocol,
231 &tag_refs,
232 &inventory_hex,
233 &mut index,
234 )
235 .map_err(|e| NapiError::from_reason(format!("render_component_templates failed: {e}")))?;
236
237 serde_json::to_string(&result)
238 .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
239}
240
241#[napi]
246pub fn protocol_tokens(protocol_data: Buffer) -> napi::Result<Vec<String>> {
247 let protocol = WebUIProtocol::from_protobuf(&protocol_data)
248 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
249
250 Ok(protocol.tokens)
251}
252
253struct CallbackWriter<'a, 'env> {
255 callback: &'a Function<'env, String, ()>,
256 error: Option<String>,
257}
258
259impl<'a, 'env> CallbackWriter<'a, 'env> {
260 fn new(callback: &'a Function<'env, String, ()>) -> Self {
261 Self {
262 callback,
263 error: None,
264 }
265 }
266}
267
268impl ResponseWriter for CallbackWriter<'_, '_> {
269 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
270 if self.error.is_some() {
271 return Ok(());
272 }
273 if let Err(e) = self.callback.call(content.to_owned()) {
274 let msg = format!("{e}");
277 if !msg.contains("Value is not undefined") {
278 self.error = Some(msg);
279 }
280 }
281 Ok(())
282 }
283
284 fn end(&mut self) -> webui_handler::Result<()> {
285 Ok(())
286 }
287}
288
289#[napi]
299#[allow(clippy::too_many_arguments)]
300pub fn render(
301 protocol_data: Buffer,
302 state_json: String,
303 entry: String,
304 request_path: String,
305 on_chunk: Function<String, ()>,
306 plugin: Option<String>,
307) -> napi::Result<()> {
308 let protocol = WebUIProtocol::from_protobuf(&protocol_data)
309 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
310
311 let state: Value = serde_json::from_str(&state_json)
312 .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))?;
313
314 let mut writer = CallbackWriter::new(&on_chunk);
315 let plugin = plugin
316 .map(|s| s.parse::<webui::Plugin>())
317 .transpose()
318 .map_err(NapiError::from_reason)?;
319 let handler = match plugin {
320 Some(webui::Plugin::Fast | webui::Plugin::FastV2) => {
321 WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
322 }
323 Some(webui::Plugin::FastV3) => {
324 WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
325 }
326 Some(webui::Plugin::WebUI) => {
327 WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
328 }
329 None => WebUIHandler::new(),
330 };
331 handler
332 .render(
333 &protocol,
334 &state,
335 &RenderOptions::new(&entry, &request_path),
336 &mut writer,
337 )
338 .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
339
340 if let Some(err) = writer.error {
341 return Err(NapiError::from_reason(format!("Callback error: {err}")));
342 }
343
344 Ok(())
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use webui_parser::HtmlParser;
351
352 fn build_protocol(html: &str) -> Vec<u8> {
354 let mut parser = HtmlParser::new();
355 parser.parse("index.html", html).expect("parse failed");
356 let tokens = parser.take_tokens();
357 let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
358 protocol.to_protobuf().expect("protobuf encode failed")
359 }
360
361 fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
363 let protocol = WebUIProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
364 let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
365
366 let mut output = String::with_capacity(1024);
367 let handler = WebUIHandler::new();
368
369 struct StringWriter<'a> {
370 output: &'a mut String,
371 }
372 impl ResponseWriter for StringWriter<'_> {
373 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
374 self.output.push_str(content);
375 Ok(())
376 }
377 fn end(&mut self) -> webui_handler::Result<()> {
378 Ok(())
379 }
380 }
381
382 let mut writer = StringWriter {
383 output: &mut output,
384 };
385 handler
386 .render(
387 &protocol,
388 &state,
389 &RenderOptions::new("index.html", "/"),
390 &mut writer,
391 )
392 .map_err(|e| e.to_string())?;
393 Ok(output)
394 }
395
396 #[test]
397 fn test_simple_passthrough() {
398 let proto = build_protocol("<p>Hello</p>");
399 let result = render_to_string(&proto, "{}");
400 assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
401 }
402
403 #[test]
404 fn test_signal_substitution() {
405 let proto = build_protocol("Hello, {{name}}!");
406 let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
407 assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
408 }
409
410 #[test]
411 fn test_for_loop() {
412 let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
413 let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
414 assert_eq!(
415 result.as_deref(),
416 Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
417 );
418 }
419
420 #[test]
421 fn test_if_condition_true() {
422 let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
423 let result = render_to_string(&proto, r#"{"show": true}"#);
424 assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
425 }
426
427 #[test]
428 fn test_if_condition_false() {
429 let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
430 let result = render_to_string(&proto, r#"{"show": false}"#);
431 assert_eq!(result.as_deref(), Ok(""));
432 }
433
434 #[test]
435 fn test_html_escaping() {
436 let proto = build_protocol("<div>{{content}}</div>");
437 let state = r#"{"content": "<script>alert('xss')</script>"}"#;
438 let result = render_to_string(&proto, state).expect("render should succeed");
439 assert!(!result.contains("<script>"));
440 assert!(result.contains("<script>"));
441 }
442
443 #[test]
444 fn test_raw_signal() {
445 let proto = build_protocol("<div>{{{content}}}</div>");
446 let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
447 assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
448 }
449
450 #[test]
451 fn test_invalid_json() {
452 let proto = build_protocol("<p>hi</p>");
453 let result = render_to_string(&proto, "NOT JSON");
454 assert!(result.is_err());
455 }
456
457 #[test]
458 fn test_empty_state() {
459 let proto = build_protocol("<p>static</p>");
460 let result = render_to_string(&proto, "{}");
461 assert_eq!(result.as_deref(), Ok("<p>static</p>"));
462 }
463
464 #[test]
465 fn test_nested_object_signal() {
466 let proto = build_protocol("{{user.name}}");
467 let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
468 assert_eq!(result.as_deref(), Ok("Alice"));
469 }
470
471 #[test]
472 fn test_invalid_protobuf() {
473 let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
474 assert!(result.is_err());
475 }
476
477 #[test]
480 fn test_build_simple_app() {
481 let dir = tempfile::TempDir::new().unwrap();
482 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
483
484 let options = JsBuildOptions {
485 app_dir: dir.path().to_string_lossy().to_string(),
486 entry: None,
487 css: None,
488 dom: None,
489 plugin: None,
490 components: None,
491 css_file_name_template: None,
492 css_public_base: None,
493 legal_comments: None,
494 };
495
496 let result = build(options).unwrap();
497 assert!(!result.protocol.is_empty());
498 assert!(result.stats.fragment_count > 0);
499 assert!(result.stats.protocol_size_bytes > 0);
500 assert!(result.stats.duration_ms >= 0.0);
501 }
502
503 #[test]
504 fn test_build_with_custom_entry() {
505 let dir = tempfile::TempDir::new().unwrap();
506 std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
507
508 let options = JsBuildOptions {
509 app_dir: dir.path().to_string_lossy().to_string(),
510 entry: Some("page.html".to_string()),
511 css: None,
512 dom: None,
513 plugin: None,
514 components: None,
515 css_file_name_template: None,
516 css_public_base: None,
517 legal_comments: None,
518 };
519
520 let result = build(options).unwrap();
521 assert!(!result.protocol.is_empty());
522 }
523
524 #[test]
525 fn test_build_missing_app_dir() {
526 let options = JsBuildOptions {
527 app_dir: "/nonexistent/path".to_string(),
528 entry: None,
529 css: None,
530 dom: None,
531 plugin: None,
532 components: None,
533 css_file_name_template: None,
534 css_public_base: None,
535 legal_comments: None,
536 };
537
538 let result = build(options);
539 assert!(result.is_err());
540 }
541
542 #[test]
543 fn test_build_invalid_css() {
544 let dir = tempfile::TempDir::new().unwrap();
545 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
546
547 let options = JsBuildOptions {
548 app_dir: dir.path().to_string_lossy().to_string(),
549 entry: None,
550 css: Some("bogus".to_string()),
551 dom: None,
552 plugin: None,
553 components: None,
554 css_file_name_template: None,
555 css_public_base: None,
556 legal_comments: None,
557 };
558
559 let result = build(options);
560 assert!(result.is_err());
561 }
562
563 #[test]
564 fn test_build_with_components_css() {
565 let dir = tempfile::TempDir::new().unwrap();
566 std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
567 std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
568 std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
569
570 let options = JsBuildOptions {
571 app_dir: dir.path().to_string_lossy().to_string(),
572 entry: None,
573 css: Some("link".to_string()),
574 dom: None,
575 plugin: None,
576 components: None,
577 css_file_name_template: None,
578 css_public_base: None,
579 legal_comments: None,
580 };
581
582 let result = build(options).unwrap();
583 assert_eq!(result.css_files.len(), 2);
585 assert_eq!(result.css_files[0], "my-card.css");
586 assert!(result.css_files[1].contains("color: red"));
587 assert_eq!(result.stats.css_file_count, 1);
588 }
589
590 #[test]
591 fn test_build_legal_comments_none_strips_legal_css() {
592 let dir = tempfile::TempDir::new().unwrap();
593 std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
594 std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
595 std::fs::write(
596 dir.path().join("my-card.css"),
597 "/*! @license MIT */ .card { color: red; }",
598 )
599 .unwrap();
600
601 let options = JsBuildOptions {
602 app_dir: dir.path().to_string_lossy().to_string(),
603 entry: None,
604 css: Some("link".to_string()),
605 dom: None,
606 plugin: None,
607 components: None,
608 css_file_name_template: None,
609 css_public_base: None,
610 legal_comments: Some("none".to_string()),
611 };
612
613 let result = build(options).unwrap();
614 assert_eq!(result.css_files[1], " .card { color: red; }");
615 }
616
617 #[test]
618 fn test_build_invalid_legal_comments() {
619 let dir = tempfile::TempDir::new().unwrap();
620 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
621
622 let options = JsBuildOptions {
623 app_dir: dir.path().to_string_lossy().to_string(),
624 entry: None,
625 css: None,
626 dom: None,
627 plugin: None,
628 components: None,
629 css_file_name_template: None,
630 css_public_base: None,
631 legal_comments: Some("linked".to_string()),
632 };
633
634 let result = build(options);
635 assert!(result.is_err());
636 }
637
638 #[test]
639 fn test_build_with_light_dom_omits_shadow_root_template() {
640 let dir = tempfile::TempDir::new().unwrap();
641 std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
642 std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
643
644 let options = JsBuildOptions {
645 app_dir: dir.path().to_string_lossy().to_string(),
646 entry: None,
647 css: None,
648 dom: Some("light".to_string()),
649 plugin: None,
650 components: None,
651 css_file_name_template: None,
652 css_public_base: None,
653 legal_comments: None,
654 };
655
656 let result = build(options).unwrap();
657 let json = inspect(result.protocol).unwrap();
658 assert!(
659 !json.contains("shadowrootmode"),
660 "light DOM build should not emit shadowrootmode wrappers, got: {json}"
661 );
662 }
663
664 #[test]
665 fn test_build_with_shadow_dom_emits_shadow_root_template() {
666 let dir = tempfile::TempDir::new().unwrap();
667 std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
668 std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
669
670 let options = JsBuildOptions {
671 app_dir: dir.path().to_string_lossy().to_string(),
672 entry: None,
673 css: None,
674 dom: Some("shadow".to_string()),
675 plugin: None,
676 components: None,
677 css_file_name_template: None,
678 css_public_base: None,
679 legal_comments: None,
680 };
681
682 let result = build(options).unwrap();
683 let json = inspect(result.protocol).unwrap();
684 assert!(
685 json.contains("shadowrootmode"),
686 "shadow DOM build should emit shadowrootmode wrappers, got: {json}"
687 );
688 }
689
690 #[test]
691 fn test_build_invalid_dom() {
692 let dir = tempfile::TempDir::new().unwrap();
693 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
694
695 let options = JsBuildOptions {
696 app_dir: dir.path().to_string_lossy().to_string(),
697 entry: None,
698 css: None,
699 dom: Some("bogus".to_string()),
700 plugin: None,
701 components: None,
702 css_file_name_template: None,
703 css_public_base: None,
704 legal_comments: None,
705 };
706
707 let result = build(options);
708 assert!(result.is_err());
709 }
710
711 #[test]
712 fn test_inspect_valid_protocol() {
713 let proto = build_protocol("<h1>Hello {{name}}</h1>");
714 let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
715 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
716 assert!(parsed.get("fragments").is_some());
717 }
718
719 #[test]
720 fn test_inspect_invalid_protocol() {
721 let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
722 assert!(result.is_err());
723 }
724
725 #[test]
728 fn test_protocol_tokens_empty() {
729 let proto = build_protocol("<p>Hello</p>");
730 let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
731 assert!(tokens.is_empty());
732 }
733
734 #[test]
735 fn test_protocol_tokens_returns_parsed_tokens() {
736 let mut parser = HtmlParser::new();
738 parser.parse("index.html", "<p>Hi</p>").expect("parse");
739 let protocol = WebUIProtocol::with_tokens(
740 parser.into_fragment_records(),
741 vec![
742 "colorBrandBackground".to_string(),
743 "fontSizeBase300".to_string(),
744 ],
745 );
746 let proto = protocol.to_protobuf().expect("encode");
747 let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
748 assert_eq!(tokens, vec!["colorBrandBackground", "fontSizeBase300"]);
749 }
750
751 #[test]
752 fn test_protocol_tokens_invalid_protobuf() {
753 let result = protocol_tokens(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
754 assert!(result.is_err());
755 }
756}