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