1use napi::bindgen_prelude::{Buffer, Function};
32use napi::Error as NapiError;
33use napi_derive::napi;
34use serde_json::Value;
35use webui_handler::plugin::fast::FastHydrationPlugin;
36use webui_handler::plugin::webui::WebUIHydrationPlugin;
37use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
38use webui_protocol::WebUIProtocol;
39
40#[napi(object)]
42pub struct JsBuildStats {
43 pub duration_ms: f64,
45 pub fragment_count: u32,
47 pub component_count: u32,
49 pub css_file_count: u32,
51 pub protocol_size_bytes: u32,
53 pub token_count: u32,
55}
56
57#[napi(object)]
59pub struct JsBuildResult {
60 pub protocol: Buffer,
62 pub css_files: Vec<String>,
64 pub stats: JsBuildStats,
66}
67
68#[napi(object)]
70pub struct JsBuildOptions {
71 pub app_dir: String,
73 pub entry: Option<String>,
75 pub css: Option<String>,
77 pub plugin: Option<String>,
79 pub components: Option<Vec<String>>,
81}
82
83#[napi]
87#[allow(clippy::cast_possible_truncation)] pub fn build(options: JsBuildOptions) -> napi::Result<JsBuildResult> {
89 let css = options
90 .css
91 .map(|s| s.parse::<webui::CssStrategy>())
92 .transpose()
93 .map_err(NapiError::from_reason)?
94 .unwrap_or_default();
95
96 let plugin = options
97 .plugin
98 .map(|s| s.parse::<webui::Plugin>())
99 .transpose()
100 .map_err(NapiError::from_reason)?;
101
102 let build_options = webui::BuildOptions {
103 app_dir: std::path::PathBuf::from(&options.app_dir),
104 entry: options.entry.unwrap_or_else(|| "index.html".to_string()),
105 css,
106 dom: webui::DomStrategy::Shadow,
107 plugin,
108 components: options.components.unwrap_or_default(),
109 };
110
111 let result = webui::build(build_options)
112 .map_err(|e| NapiError::from_reason(format!("Build error: {e}")))?;
113
114 let css_files: Vec<String> = result
116 .css_files
117 .into_iter()
118 .flat_map(|(name, content)| [name, content])
119 .collect();
120
121 Ok(JsBuildResult {
122 protocol: Buffer::from(result.protocol_bytes),
123 css_files,
124 stats: JsBuildStats {
125 duration_ms: result.stats.duration.as_secs_f64() * 1000.0,
126 fragment_count: result.stats.fragment_count as u32,
127 component_count: result.stats.component_count as u32,
128 css_file_count: result.stats.css_file_count as u32,
129 protocol_size_bytes: result.stats.protocol_size_bytes as u32,
130 token_count: result.stats.token_count as u32,
131 },
132 })
133}
134
135#[napi]
137pub fn inspect(protocol_data: Buffer) -> napi::Result<String> {
138 webui::inspect_bytes(&protocol_data)
139 .map_err(|e| NapiError::from_reason(format!("Inspect error: {e}")))
140}
141
142#[napi]
150pub fn render_partial(
151 protocol_data: Buffer,
152 state_json: String,
153 entry_id: String,
154 request_path: String,
155 inventory_hex: String,
156) -> napi::Result<String> {
157 let protocol = WebUIProtocol::from_protobuf(&protocol_data)
158 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
159
160 let state: serde_json::Value = serde_json::from_str(&state_json)
161 .map_err(|e| NapiError::from_reason(format!("invalid state JSON: {e}")))?;
162
163 let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
168
169 let mut result = webui_handler::route_handler::render_partial(
170 &protocol,
171 &entry_id,
172 &request_path,
173 &inventory_hex,
174 &mut index,
175 )
176 .map_err(|e| NapiError::from_reason(format!("render_partial failed: {e}")))?;
177 if let Some(obj) = result.as_object_mut() {
178 obj.insert("state".into(), state);
179 }
180
181 serde_json::to_string(&result)
182 .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
183}
184
185#[napi]
186pub fn render_component_templates(
187 protocol_data: Buffer,
188 component_tags_json: String,
189 inventory_hex: String,
190) -> napi::Result<String> {
191 let protocol = WebUIProtocol::from_protobuf(&protocol_data)
192 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
193
194 let tags: Vec<String> = serde_json::from_str(&component_tags_json)
195 .map_err(|e| NapiError::from_reason(format!("invalid tags JSON: {e}")))?;
196 let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
197
198 let index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
200
201 let result = webui_handler::route_handler::render_component_templates(
202 &protocol,
203 &tag_refs,
204 &inventory_hex,
205 &index,
206 )
207 .map_err(|e| NapiError::from_reason(format!("render_component_templates failed: {e}")))?;
208
209 serde_json::to_string(&result)
210 .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
211}
212
213#[napi]
218pub fn protocol_tokens(protocol_data: Buffer) -> napi::Result<Vec<String>> {
219 let protocol = WebUIProtocol::from_protobuf(&protocol_data)
220 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
221
222 Ok(protocol.tokens)
223}
224
225struct CallbackWriter<'a, 'env> {
227 callback: &'a Function<'env, String, ()>,
228 error: Option<String>,
229}
230
231impl<'a, 'env> CallbackWriter<'a, 'env> {
232 fn new(callback: &'a Function<'env, String, ()>) -> Self {
233 Self {
234 callback,
235 error: None,
236 }
237 }
238}
239
240impl ResponseWriter for CallbackWriter<'_, '_> {
241 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
242 if self.error.is_some() {
243 return Ok(());
244 }
245 if let Err(e) = self.callback.call(content.to_owned()) {
246 let msg = format!("{e}");
249 if !msg.contains("Value is not undefined") {
250 self.error = Some(msg);
251 }
252 }
253 Ok(())
254 }
255
256 fn end(&mut self) -> webui_handler::Result<()> {
257 Ok(())
258 }
259}
260
261#[napi]
271#[allow(clippy::too_many_arguments)]
272pub fn render(
273 protocol_data: Buffer,
274 state_json: String,
275 entry: String,
276 request_path: String,
277 on_chunk: Function<String, ()>,
278 plugin: Option<String>,
279) -> napi::Result<()> {
280 let protocol = WebUIProtocol::from_protobuf(&protocol_data)
281 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
282
283 let state: Value = serde_json::from_str(&state_json)
284 .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))?;
285
286 let mut writer = CallbackWriter::new(&on_chunk);
287 let plugin = plugin
288 .map(|s| s.parse::<webui::Plugin>())
289 .transpose()
290 .map_err(NapiError::from_reason)?;
291 let handler = match plugin {
292 Some(webui::Plugin::Fast) => {
293 WebUIHandler::with_plugin(|| Box::new(FastHydrationPlugin::new()))
294 }
295 Some(webui::Plugin::WebUI) => {
296 WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
297 }
298 None => WebUIHandler::new(),
299 };
300 handler
301 .render(
302 &protocol,
303 &state,
304 &RenderOptions::new(&entry, &request_path),
305 &mut writer,
306 )
307 .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
308
309 if let Some(err) = writer.error {
310 return Err(NapiError::from_reason(format!("Callback error: {err}")));
311 }
312
313 Ok(())
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use webui_parser::HtmlParser;
320
321 fn build_protocol(html: &str) -> Vec<u8> {
323 let mut parser = HtmlParser::new();
324 parser.parse("index.html", html).expect("parse failed");
325 let tokens = parser.take_tokens();
326 let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
327 protocol.to_protobuf().expect("protobuf encode failed")
328 }
329
330 fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
332 let protocol = WebUIProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
333 let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
334
335 let mut output = String::with_capacity(1024);
336 let handler = WebUIHandler::new();
337
338 struct StringWriter<'a> {
339 output: &'a mut String,
340 }
341 impl ResponseWriter for StringWriter<'_> {
342 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
343 self.output.push_str(content);
344 Ok(())
345 }
346 fn end(&mut self) -> webui_handler::Result<()> {
347 Ok(())
348 }
349 }
350
351 let mut writer = StringWriter {
352 output: &mut output,
353 };
354 handler
355 .render(
356 &protocol,
357 &state,
358 &RenderOptions::new("index.html", "/"),
359 &mut writer,
360 )
361 .map_err(|e| e.to_string())?;
362 Ok(output)
363 }
364
365 #[test]
366 fn test_simple_passthrough() {
367 let proto = build_protocol("<p>Hello</p>");
368 let result = render_to_string(&proto, "{}");
369 assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
370 }
371
372 #[test]
373 fn test_signal_substitution() {
374 let proto = build_protocol("Hello, {{name}}!");
375 let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
376 assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
377 }
378
379 #[test]
380 fn test_for_loop() {
381 let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
382 let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
383 assert_eq!(
384 result.as_deref(),
385 Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
386 );
387 }
388
389 #[test]
390 fn test_if_condition_true() {
391 let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
392 let result = render_to_string(&proto, r#"{"show": true}"#);
393 assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
394 }
395
396 #[test]
397 fn test_if_condition_false() {
398 let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
399 let result = render_to_string(&proto, r#"{"show": false}"#);
400 assert_eq!(result.as_deref(), Ok(""));
401 }
402
403 #[test]
404 fn test_html_escaping() {
405 let proto = build_protocol("<div>{{content}}</div>");
406 let state = r#"{"content": "<script>alert('xss')</script>"}"#;
407 let result = render_to_string(&proto, state).expect("render should succeed");
408 assert!(!result.contains("<script>"));
409 assert!(result.contains("<script>"));
410 }
411
412 #[test]
413 fn test_raw_signal() {
414 let proto = build_protocol("<div>{{{content}}}</div>");
415 let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
416 assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
417 }
418
419 #[test]
420 fn test_invalid_json() {
421 let proto = build_protocol("<p>hi</p>");
422 let result = render_to_string(&proto, "NOT JSON");
423 assert!(result.is_err());
424 }
425
426 #[test]
427 fn test_empty_state() {
428 let proto = build_protocol("<p>static</p>");
429 let result = render_to_string(&proto, "{}");
430 assert_eq!(result.as_deref(), Ok("<p>static</p>"));
431 }
432
433 #[test]
434 fn test_nested_object_signal() {
435 let proto = build_protocol("{{user.name}}");
436 let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
437 assert_eq!(result.as_deref(), Ok("Alice"));
438 }
439
440 #[test]
441 fn test_invalid_protobuf() {
442 let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
443 assert!(result.is_err());
444 }
445
446 #[test]
449 fn test_build_simple_app() {
450 let dir = tempfile::TempDir::new().unwrap();
451 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
452
453 let options = JsBuildOptions {
454 app_dir: dir.path().to_string_lossy().to_string(),
455 entry: None,
456 css: None,
457 plugin: None,
458 components: None,
459 };
460
461 let result = build(options).unwrap();
462 assert!(!result.protocol.is_empty());
463 assert!(result.stats.fragment_count > 0);
464 assert!(result.stats.protocol_size_bytes > 0);
465 assert!(result.stats.duration_ms >= 0.0);
466 }
467
468 #[test]
469 fn test_build_with_custom_entry() {
470 let dir = tempfile::TempDir::new().unwrap();
471 std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
472
473 let options = JsBuildOptions {
474 app_dir: dir.path().to_string_lossy().to_string(),
475 entry: Some("page.html".to_string()),
476 css: None,
477 plugin: None,
478 components: None,
479 };
480
481 let result = build(options).unwrap();
482 assert!(!result.protocol.is_empty());
483 }
484
485 #[test]
486 fn test_build_missing_app_dir() {
487 let options = JsBuildOptions {
488 app_dir: "/nonexistent/path".to_string(),
489 entry: None,
490 css: None,
491 plugin: None,
492 components: None,
493 };
494
495 let result = build(options);
496 assert!(result.is_err());
497 }
498
499 #[test]
500 fn test_build_invalid_css() {
501 let dir = tempfile::TempDir::new().unwrap();
502 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
503
504 let options = JsBuildOptions {
505 app_dir: dir.path().to_string_lossy().to_string(),
506 entry: None,
507 css: Some("bogus".to_string()),
508 plugin: None,
509 components: None,
510 };
511
512 let result = build(options);
513 assert!(result.is_err());
514 }
515
516 #[test]
517 fn test_build_with_components_css() {
518 let dir = tempfile::TempDir::new().unwrap();
519 std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
520 std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
521 std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
522
523 let options = JsBuildOptions {
524 app_dir: dir.path().to_string_lossy().to_string(),
525 entry: None,
526 css: Some("link".to_string()),
527 plugin: None,
528 components: None,
529 };
530
531 let result = build(options).unwrap();
532 assert_eq!(result.css_files.len(), 2);
534 assert_eq!(result.css_files[0], "my-card.css");
535 assert!(result.css_files[1].contains("color: red"));
536 assert_eq!(result.stats.css_file_count, 1);
537 }
538
539 #[test]
540 fn test_inspect_valid_protocol() {
541 let proto = build_protocol("<h1>Hello {{name}}</h1>");
542 let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
543 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
544 assert!(parsed.get("fragments").is_some());
545 }
546
547 #[test]
548 fn test_inspect_invalid_protocol() {
549 let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
550 assert!(result.is_err());
551 }
552
553 #[test]
556 fn test_protocol_tokens_empty() {
557 let proto = build_protocol("<p>Hello</p>");
558 let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
559 assert!(tokens.is_empty());
560 }
561
562 #[test]
563 fn test_protocol_tokens_returns_parsed_tokens() {
564 let mut parser = HtmlParser::new();
566 parser.parse("index.html", "<p>Hi</p>").expect("parse");
567 let protocol = WebUIProtocol::with_tokens(
568 parser.into_fragment_records(),
569 vec![
570 "colorBrandBackground".to_string(),
571 "fontSizeBase300".to_string(),
572 ],
573 );
574 let proto = protocol.to_protobuf().expect("encode");
575 let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
576 assert_eq!(tokens, vec!["colorBrandBackground", "fontSizeBase300"]);
577 }
578
579 #[test]
580 fn test_protocol_tokens_invalid_protobuf() {
581 let result = protocol_tokens(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
582 assert!(result.is_err());
583 }
584}