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