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 result = webui_handler::route_handler::render_partial(
164 &protocol,
165 state,
166 &entry_id,
167 &request_path,
168 &inventory_hex,
169 );
170
171 serde_json::to_string(&result)
172 .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
173}
174
175#[napi]
176pub fn render_component_templates(
177 protocol_data: Buffer,
178 component_tags_json: String,
179 inventory_hex: String,
180) -> napi::Result<String> {
181 let protocol = WebUIProtocol::from_protobuf(&protocol_data)
182 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
183
184 let tags: Vec<String> = serde_json::from_str(&component_tags_json)
185 .map_err(|e| NapiError::from_reason(format!("invalid tags JSON: {e}")))?;
186 let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
187
188 let result = webui_handler::route_handler::render_component_templates(
189 &protocol,
190 &tag_refs,
191 &inventory_hex,
192 );
193
194 serde_json::to_string(&result)
195 .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
196}
197
198struct CallbackWriter<'a, 'env> {
200 callback: &'a Function<'env, String, ()>,
201 error: Option<String>,
202}
203
204impl<'a, 'env> CallbackWriter<'a, 'env> {
205 fn new(callback: &'a Function<'env, String, ()>) -> Self {
206 Self {
207 callback,
208 error: None,
209 }
210 }
211}
212
213impl ResponseWriter for CallbackWriter<'_, '_> {
214 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
215 if self.error.is_some() {
216 return Ok(());
217 }
218 if let Err(e) = self.callback.call(content.to_owned()) {
219 let msg = format!("{e}");
222 if !msg.contains("Value is not undefined") {
223 self.error = Some(msg);
224 }
225 }
226 Ok(())
227 }
228
229 fn end(&mut self) -> webui_handler::Result<()> {
230 Ok(())
231 }
232}
233
234#[napi]
244#[allow(clippy::too_many_arguments)]
245pub fn render(
246 protocol_data: Buffer,
247 state_json: String,
248 entry: String,
249 request_path: String,
250 on_chunk: Function<String, ()>,
251 plugin: Option<String>,
252) -> napi::Result<()> {
253 let protocol = WebUIProtocol::from_protobuf(&protocol_data)
254 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
255
256 let state: Value = serde_json::from_str(&state_json)
257 .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))?;
258
259 let mut writer = CallbackWriter::new(&on_chunk);
260 let plugin = plugin
261 .map(|s| s.parse::<webui::Plugin>())
262 .transpose()
263 .map_err(NapiError::from_reason)?;
264 let handler = match plugin {
265 Some(webui::Plugin::Fast) => {
266 WebUIHandler::with_plugin(|| Box::new(FastHydrationPlugin::new()))
267 }
268 Some(webui::Plugin::WebUI) => {
269 WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
270 }
271 None => WebUIHandler::new(),
272 };
273 handler
274 .render(
275 &protocol,
276 &state,
277 &RenderOptions::new(&entry, &request_path),
278 &mut writer,
279 )
280 .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
281
282 if let Some(err) = writer.error {
283 return Err(NapiError::from_reason(format!("Callback error: {err}")));
284 }
285
286 Ok(())
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use webui_parser::HtmlParser;
293
294 fn build_protocol(html: &str) -> Vec<u8> {
296 let mut parser = HtmlParser::new();
297 parser.parse("index.html", html).expect("parse failed");
298 let tokens = parser.take_tokens();
299 let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
300 protocol.to_protobuf().expect("protobuf encode failed")
301 }
302
303 fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
305 let protocol = WebUIProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
306 let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
307
308 let mut output = String::with_capacity(1024);
309 let handler = WebUIHandler::new();
310
311 struct StringWriter<'a> {
312 output: &'a mut String,
313 }
314 impl ResponseWriter for StringWriter<'_> {
315 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
316 self.output.push_str(content);
317 Ok(())
318 }
319 fn end(&mut self) -> webui_handler::Result<()> {
320 Ok(())
321 }
322 }
323
324 let mut writer = StringWriter {
325 output: &mut output,
326 };
327 handler
328 .render(
329 &protocol,
330 &state,
331 &RenderOptions::new("index.html", "/"),
332 &mut writer,
333 )
334 .map_err(|e| e.to_string())?;
335 Ok(output)
336 }
337
338 #[test]
339 fn test_simple_passthrough() {
340 let proto = build_protocol("<p>Hello</p>");
341 let result = render_to_string(&proto, "{}");
342 assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
343 }
344
345 #[test]
346 fn test_signal_substitution() {
347 let proto = build_protocol("Hello, {{name}}!");
348 let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
349 assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
350 }
351
352 #[test]
353 fn test_for_loop() {
354 let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
355 let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
356 assert_eq!(
357 result.as_deref(),
358 Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
359 );
360 }
361
362 #[test]
363 fn test_if_condition_true() {
364 let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
365 let result = render_to_string(&proto, r#"{"show": true}"#);
366 assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
367 }
368
369 #[test]
370 fn test_if_condition_false() {
371 let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
372 let result = render_to_string(&proto, r#"{"show": false}"#);
373 assert_eq!(result.as_deref(), Ok(""));
374 }
375
376 #[test]
377 fn test_html_escaping() {
378 let proto = build_protocol("<div>{{content}}</div>");
379 let state = r#"{"content": "<script>alert('xss')</script>"}"#;
380 let result = render_to_string(&proto, state).expect("render should succeed");
381 assert!(!result.contains("<script>"));
382 assert!(result.contains("<script>"));
383 }
384
385 #[test]
386 fn test_raw_signal() {
387 let proto = build_protocol("<div>{{{content}}}</div>");
388 let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
389 assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
390 }
391
392 #[test]
393 fn test_invalid_json() {
394 let proto = build_protocol("<p>hi</p>");
395 let result = render_to_string(&proto, "NOT JSON");
396 assert!(result.is_err());
397 }
398
399 #[test]
400 fn test_empty_state() {
401 let proto = build_protocol("<p>static</p>");
402 let result = render_to_string(&proto, "{}");
403 assert_eq!(result.as_deref(), Ok("<p>static</p>"));
404 }
405
406 #[test]
407 fn test_nested_object_signal() {
408 let proto = build_protocol("{{user.name}}");
409 let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
410 assert_eq!(result.as_deref(), Ok("Alice"));
411 }
412
413 #[test]
414 fn test_invalid_protobuf() {
415 let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
416 assert!(result.is_err());
417 }
418
419 #[test]
422 fn test_build_simple_app() {
423 let dir = tempfile::TempDir::new().unwrap();
424 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
425
426 let options = JsBuildOptions {
427 app_dir: dir.path().to_string_lossy().to_string(),
428 entry: None,
429 css: None,
430 plugin: None,
431 components: None,
432 };
433
434 let result = build(options).unwrap();
435 assert!(!result.protocol.is_empty());
436 assert!(result.stats.fragment_count > 0);
437 assert!(result.stats.protocol_size_bytes > 0);
438 assert!(result.stats.duration_ms >= 0.0);
439 }
440
441 #[test]
442 fn test_build_with_custom_entry() {
443 let dir = tempfile::TempDir::new().unwrap();
444 std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
445
446 let options = JsBuildOptions {
447 app_dir: dir.path().to_string_lossy().to_string(),
448 entry: Some("page.html".to_string()),
449 css: None,
450 plugin: None,
451 components: None,
452 };
453
454 let result = build(options).unwrap();
455 assert!(!result.protocol.is_empty());
456 }
457
458 #[test]
459 fn test_build_missing_app_dir() {
460 let options = JsBuildOptions {
461 app_dir: "/nonexistent/path".to_string(),
462 entry: None,
463 css: None,
464 plugin: None,
465 components: None,
466 };
467
468 let result = build(options);
469 assert!(result.is_err());
470 }
471
472 #[test]
473 fn test_build_invalid_css() {
474 let dir = tempfile::TempDir::new().unwrap();
475 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
476
477 let options = JsBuildOptions {
478 app_dir: dir.path().to_string_lossy().to_string(),
479 entry: None,
480 css: Some("bogus".to_string()),
481 plugin: None,
482 components: None,
483 };
484
485 let result = build(options);
486 assert!(result.is_err());
487 }
488
489 #[test]
490 fn test_build_with_components_css() {
491 let dir = tempfile::TempDir::new().unwrap();
492 std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
493 std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
494 std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
495
496 let options = JsBuildOptions {
497 app_dir: dir.path().to_string_lossy().to_string(),
498 entry: None,
499 css: Some("link".to_string()),
500 plugin: None,
501 components: None,
502 };
503
504 let result = build(options).unwrap();
505 assert_eq!(result.css_files.len(), 2);
507 assert_eq!(result.css_files[0], "my-card.css");
508 assert!(result.css_files[1].contains("color: red"));
509 assert_eq!(result.stats.css_file_count, 1);
510 }
511
512 #[test]
513 fn test_inspect_valid_protocol() {
514 let proto = build_protocol("<h1>Hello {{name}}</h1>");
515 let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
516 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
517 assert!(parsed.get("fragments").is_some());
518 }
519
520 #[test]
521 fn test_inspect_invalid_protocol() {
522 let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
523 assert!(result.is_err());
524 }
525}