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