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