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