1use std::sync::atomic::{AtomicUsize, Ordering};
31use std::sync::Arc;
32
33use napi::bindgen_prelude::{Buffer, Either, External, Function};
34use napi::Error as NapiError;
35use napi_derive::napi;
36use serde_json::Value;
37use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
38use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
39use webui_handler::plugin::webui::WebUIHydrationPlugin;
40use webui_handler::{
41 BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, HandlerError,
42 Protocol as HandlerProtocol, RenderOptions, ResponseWriter, SessionOptions,
43 StreamStep as HandlerStreamStep, StreamingSession as HandlerStreamingSession, WebUIHandler,
44};
45#[cfg(test)]
46use webui_protocol::WebUIProtocol;
47
48const STREAM_CHUNK_SIZE: usize = 16 * 1024;
49const INITIAL_RENDER_CAPACITY: usize = 4 * 1024;
50const MAX_RENDER_CAPACITY_HINT: usize = 1024 * 1024;
51const RENDER_CAPACITY_BUCKETS: usize = 64;
52const RENDER_CAPACITY_MASK: usize = (1 << 21) - 1;
53const _: () = assert!(RENDER_CAPACITY_BUCKETS.is_power_of_two());
54const _: () = assert!(MAX_RENDER_CAPACITY_HINT <= RENDER_CAPACITY_MASK);
55
56#[cfg(target_pointer_width = "64")]
57const CAPACITY_HASH_OFFSET: usize = 14_695_981_039_346_656_037;
58#[cfg(target_pointer_width = "64")]
59const CAPACITY_HASH_PRIME: usize = 1_099_511_628_211;
60#[cfg(target_pointer_width = "32")]
61const CAPACITY_HASH_OFFSET: usize = 2_166_136_261;
62#[cfg(target_pointer_width = "32")]
63const CAPACITY_HASH_PRIME: usize = 16_777_619;
64
65struct RenderCapacityHints {
69 buckets: [AtomicUsize; RENDER_CAPACITY_BUCKETS],
70}
71
72impl RenderCapacityHints {
73 fn new() -> Self {
74 Self {
75 buckets: std::array::from_fn(|_| AtomicUsize::new(0)),
76 }
77 }
78
79 fn load(&self, entry_id: &str, request_path: &str) -> usize {
80 let hash = capacity_hint_hash(entry_id, request_path);
81 let encoded = self.buckets[hash & (RENDER_CAPACITY_BUCKETS - 1)].load(Ordering::Relaxed);
82 if encoded & !RENDER_CAPACITY_MASK == capacity_hint_fingerprint(hash) {
83 encoded & RENDER_CAPACITY_MASK
84 } else {
85 INITIAL_RENDER_CAPACITY
86 }
87 }
88
89 fn store(&self, entry_id: &str, request_path: &str, capacity: usize) {
90 let hash = capacity_hint_hash(entry_id, request_path);
91 let encoded = capacity_hint_fingerprint(hash)
92 | capacity.clamp(INITIAL_RENDER_CAPACITY, MAX_RENDER_CAPACITY_HINT);
93 self.buckets[hash & (RENDER_CAPACITY_BUCKETS - 1)].store(encoded, Ordering::Relaxed);
94 }
95}
96
97fn capacity_hint_hash(entry_id: &str, request_path: &str) -> usize {
98 let mut hash = CAPACITY_HASH_OFFSET;
99 for byte in entry_id
100 .bytes()
101 .chain(std::iter::once(u8::MAX))
102 .chain(request_path.bytes())
103 {
104 hash ^= usize::from(byte);
105 hash = hash.wrapping_mul(CAPACITY_HASH_PRIME);
106 }
107 hash
108}
109
110fn capacity_hint_fingerprint(hash: usize) -> usize {
111 let fingerprint = hash.rotate_left(7) & !RENDER_CAPACITY_MASK;
112 if fingerprint == 0 {
113 RENDER_CAPACITY_MASK + 1
114 } else {
115 fingerprint
116 }
117}
118
119#[napi(object)]
121pub struct JsBuildStats {
122 pub duration_ms: f64,
124 pub fragment_count: u32,
126 pub component_count: u32,
128 pub css_file_count: u32,
130 pub protocol_size_bytes: u32,
132 pub token_count: u32,
134}
135
136#[napi(object)]
138pub struct JsBuildResult {
139 pub protocol: Buffer,
141 pub css_files: Vec<String>,
143 pub component_asset_files: Vec<String>,
145 pub metafile: Option<String>,
147 pub warnings: Vec<String>,
150 pub stats: JsBuildStats,
152}
153
154#[napi(object)]
156pub struct JsProjectionManifest {
157 pub path: String,
159 pub json: String,
161}
162
163#[napi(object)]
165pub struct JsBuildOptions {
166 pub app_dir: String,
168 pub entry: Option<String>,
170 pub css: Option<String>,
172 pub dom: Option<String>,
174 pub css_bundle: Option<bool>,
179 pub plugin: Option<String>,
181 pub components: Option<Vec<String>>,
183 pub component_asset_roots: Option<Vec<String>>,
185 pub metafile: Option<bool>,
187 pub css_file_name_template: Option<String>,
189 pub css_public_base: Option<String>,
191 pub legal_comments: Option<String>,
193 pub theme: Option<String>,
195 pub projection_manifests: Option<Vec<String>>,
197 pub projection_manifest_objects: Option<Vec<JsProjectionManifest>>,
199}
200
201#[napi]
205#[allow(clippy::cast_possible_truncation)] pub fn build(options: JsBuildOptions) -> napi::Result<JsBuildResult> {
207 let css = options
208 .css
209 .map(|s| s.parse::<webui::CssStrategy>())
210 .transpose()
211 .map_err(NapiError::from_reason)?
212 .unwrap_or_default();
213 let dom = options
214 .dom
215 .map(|value| value.parse::<webui::DomStrategy>())
216 .transpose()
217 .map_err(NapiError::from_reason)?
218 .unwrap_or_default();
219
220 let plugin = options
221 .plugin
222 .map(|s| s.parse::<webui::Plugin>())
223 .transpose()
224 .map_err(NapiError::from_reason)?;
225
226 let legal_comments = options
227 .legal_comments
228 .map(|s| s.parse::<webui::LegalComments>())
229 .transpose()
230 .map_err(NapiError::from_reason)?
231 .unwrap_or_default();
232
233 let app_dir = std::path::PathBuf::from(&options.app_dir);
234 let theme = options
235 .theme
236 .as_deref()
237 .map(|theme| load_theme(theme, &app_dir))
238 .transpose()?;
239 let mut projection_manifests: Vec<webui::ProjectionManifestSource> = options
240 .projection_manifests
241 .unwrap_or_default()
242 .into_iter()
243 .map(std::path::PathBuf::from)
244 .map(Into::into)
245 .collect();
246 projection_manifests.extend(
247 options
248 .projection_manifest_objects
249 .unwrap_or_default()
250 .into_iter()
251 .map(|manifest| webui::ProjectionManifestSource::Inline {
252 manifest_path: std::path::PathBuf::from(manifest.path),
253 json: manifest.json,
254 }),
255 );
256
257 let build_options = webui::BuildOptions {
258 app_dir,
259 entry: options.entry.unwrap_or_else(|| "index.html".to_string()),
260 css,
261 dom,
262 css_bundle: options.css_bundle.unwrap_or(false),
263 plugin,
264 components: options.components.unwrap_or_default(),
265 component_asset_roots: options.component_asset_roots.unwrap_or_default(),
266 metafile: options.metafile.unwrap_or(false),
267 css_file_name_template: options
268 .css_file_name_template
269 .unwrap_or_else(|| webui::DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string()),
270 css_public_base: options.css_public_base,
271 legal_comments,
272 theme,
273 projection_manifests,
274 };
275
276 let result = webui::build(build_options)
277 .map_err(|e| NapiError::from_reason(format!("Build error: {}", e.chain_message())))?;
278
279 let css_files: Vec<String> = result
281 .css_files
282 .into_iter()
283 .flat_map(|(name, content)| [name, content])
284 .collect();
285 let component_asset_files: Vec<String> = result
286 .component_asset_files
287 .into_iter()
288 .flat_map(|file| [file.name, file.content])
289 .collect();
290 let warnings: Vec<String> = result.warnings.iter().map(|d| d.to_string()).collect();
291
292 Ok(JsBuildResult {
293 protocol: Buffer::from(result.protocol_bytes),
294 css_files,
295 component_asset_files,
296 metafile: result.metafile,
297 warnings,
298 stats: JsBuildStats {
299 duration_ms: result.stats.duration.as_secs_f64() * 1000.0,
300 fragment_count: result.stats.fragment_count as u32,
301 component_count: result.stats.component_count as u32,
302 css_file_count: result.stats.css_file_count as u32,
303 protocol_size_bytes: result.stats.protocol_size_bytes as u32,
304 token_count: result.stats.token_count as u32,
305 },
306 })
307}
308
309fn load_theme(theme: &str, search_root: &std::path::Path) -> napi::Result<webui::TokenFile> {
310 let resolved = webui::resolve_theme_path(theme, search_root)
311 .map_err(|e| NapiError::from_reason(format!("Theme resolution error: {e}")))?;
312 webui::load_token_file(&resolved).map_err(|e| {
313 NapiError::from_reason(format!("Theme load error for {}: {e}", resolved.display()))
314 })
315}
316
317#[napi]
319pub fn inspect(protocol_data: Buffer) -> napi::Result<String> {
320 webui::inspect_bytes(&protocol_data)
321 .map_err(|e| NapiError::from_reason(format!("Inspect error: {e}")))
322}
323
324#[napi]
330pub struct Protocol {
331 inner: Arc<HandlerProtocol>,
332 handler: Arc<WebUIHandler>,
333 output_capacity_hints: RenderCapacityHints,
334}
335
336#[napi]
337impl Protocol {
338 #[napi(constructor)]
340 pub fn new(protocol_data: Buffer, plugin: Option<String>) -> napi::Result<Self> {
341 let inner = Arc::new(decode_protocol(&protocol_data)?);
342 let handler = Arc::new(create_handler(plugin)?);
343 Ok(Self {
344 inner,
345 handler,
346 output_capacity_hints: RenderCapacityHints::new(),
347 })
348 }
349
350 #[napi]
352 pub fn render(
353 &self,
354 state_json: String,
355 entry: String,
356 request_path: String,
357 ) -> napi::Result<Buffer> {
358 let state = parse_state_json(&state_json)?;
359 let options = RenderOptions::new(&entry, &request_path);
360 self.render_buffer(&state, &options)
361 }
362
363 #[napi]
368 pub fn prepare_state(&self, state_json: String) -> napi::Result<External<Value>> {
369 let size_hint = state_json.len().saturating_mul(2);
370 Ok(External::new_with_size_hint(
371 parse_state_json(&state_json)?,
372 size_hint,
373 ))
374 }
375
376 #[napi]
378 pub fn render_prepared(
379 &self,
380 state: &External<Value>,
381 entry: String,
382 request_path: String,
383 ) -> napi::Result<Buffer> {
384 let options = RenderOptions::new(&entry, &request_path);
385 self.render_buffer(state.as_ref(), &options)
386 }
387
388 #[napi]
390 pub fn render_stream(
391 &self,
392 state_json: String,
393 entry: String,
394 request_path: String,
395 on_chunk: Function<String>,
396 ) -> napi::Result<()> {
397 let state = parse_state_json(&state_json)?;
398 let options = RenderOptions::new(&entry, &request_path);
399 render_to_callback(&self.handler, &self.inner, &state, &options, &on_chunk)
400 }
401
402 #[napi]
404 pub fn render_partial(
405 &self,
406 state_json: String,
407 entry_id: String,
408 request_path: String,
409 inventory_hex: String,
410 ) -> napi::Result<String> {
411 self.inner
412 .render_partial_json(&state_json, &entry_id, &request_path, &inventory_hex)
413 .map_err(|e| NapiError::from_reason(format!("render_partial failed: {e}")))
414 }
415
416 #[napi]
418 pub fn render_component_templates(
419 &self,
420 component_tags: Vec<String>,
421 inventory_hex: String,
422 ) -> napi::Result<String> {
423 let tag_refs: Vec<&str> = component_tags.iter().map(String::as_str).collect();
424 let result = self
425 .inner
426 .render_component_templates(&tag_refs, &inventory_hex)
427 .map_err(|e| {
428 NapiError::from_reason(format!("render_component_templates failed: {e}"))
429 })?;
430 serde_json::to_string(&result)
431 .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
432 }
433
434 #[napi]
436 pub fn tokens(&self) -> Vec<String> {
437 self.inner.tokens().to_vec()
438 }
439
440 #[napi]
446 pub fn stream_response(
447 &self,
448 entry: String,
449 request_path: String,
450 options: Option<JsStreamOptions>,
451 ) -> napi::Result<StreamingSession> {
452 let options = options.unwrap_or_default();
453 let mut session_options = SessionOptions::new(entry, request_path);
454 session_options.nonce = options.nonce;
455 session_options.head_inject = options.head_inject;
456 session_options.body_inject = options.body_inject;
457 let inner = HandlerStreamingSession::new(
458 Arc::clone(&self.handler),
459 Arc::clone(&self.inner),
460 session_options,
461 )
462 .map_err(streaming_error)?;
463 Ok(StreamingSession { inner })
464 }
465}
466
467impl Protocol {
468 fn render_buffer(&self, state: &Value, options: &RenderOptions<'_>) -> napi::Result<Buffer> {
469 let capacity = self
470 .output_capacity_hints
471 .load(options.entry_id, options.request_path);
472 let mut html = render_to_string(&self.handler, &self.inner, state, options, capacity)?;
473 self.output_capacity_hints
474 .store(options.entry_id, options.request_path, html.len());
475 if html.capacity() > html.len().saturating_mul(2) {
477 html.shrink_to_fit();
478 }
479 Ok(Buffer::from(html.into_bytes()))
481 }
482}
483
484#[napi(object)]
486#[derive(Default)]
487pub struct JsStreamOptions {
488 pub nonce: Option<String>,
490 pub head_inject: Option<String>,
492 pub body_inject: Option<String>,
494}
495
496#[napi]
517pub struct StreamingSession {
518 inner: HandlerStreamingSession,
519}
520
521#[napi(object)]
523pub struct JsBoundaryDescriptor {
524 pub instance_id: u32,
526 pub declaration_id: u32,
528 pub owner: String,
530 pub name: String,
532 pub key: Option<Either<String, f64>>,
534}
535
536#[napi(object)]
538pub struct JsStreamStep {
539 pub bytes: Buffer,
541 pub done: bool,
543 pub boundary: Option<JsBoundaryDescriptor>,
545}
546
547#[napi]
548impl StreamingSession {
549 #[napi]
551 pub fn start(&mut self, state_json: String) -> napi::Result<JsStreamStep> {
552 let state = parse_state_json(&state_json)?;
553 self.inner
554 .start(state)
555 .and_then(stream_step)
556 .map_err(streaming_error)
557 }
558
559 #[napi]
564 pub fn resume(
565 &mut self,
566 instance_id: u32,
567 state_json: String,
568 mode: Option<String>,
569 ) -> napi::Result<JsStreamStep> {
570 let state = parse_state_json(&state_json)?;
571 let mode = parse_boundary_mode(mode.as_deref())?;
572 self.inner
573 .resume(BoundaryInstanceId::from_raw(instance_id), state, mode)
574 .and_then(stream_step)
575 .map_err(streaming_error)
576 }
577
578 #[napi]
583 pub fn advance(&mut self) -> napi::Result<JsStreamStep> {
584 self.inner
585 .advance()
586 .and_then(stream_step)
587 .map_err(streaming_error)
588 }
589
590 #[napi]
592 pub fn update(&mut self, instance_id: u32, patch_json: String) -> napi::Result<Buffer> {
593 let patch = parse_state_json(&patch_json)?;
594 self.inner
595 .update(BoundaryInstanceId::from_raw(instance_id), &patch)
596 .map(Buffer::from)
597 .map_err(streaming_error)
598 }
599}
600
601fn stream_step(step: HandlerStreamStep) -> webui_handler::Result<JsStreamStep> {
602 Ok(JsStreamStep {
603 bytes: Buffer::from(step.bytes),
604 done: step.done,
605 boundary: step.boundary.map(boundary_descriptor).transpose()?,
606 })
607}
608
609fn boundary_descriptor(
610 boundary: BoundaryDescriptor,
611) -> webui_handler::Result<JsBoundaryDescriptor> {
612 Ok(JsBoundaryDescriptor {
613 instance_id: boundary.instance_id.raw(),
614 declaration_id: boundary.declaration_id,
615 owner: boundary.owner.to_string(),
616 name: boundary.name.to_string(),
617 key: boundary.key.map(boundary_key).transpose()?,
618 })
619}
620
621fn boundary_key(key: BoundaryKey) -> webui_handler::Result<Either<String, f64>> {
622 match key {
623 BoundaryKey::String(value) => Ok(Either::A(value)),
624 BoundaryKey::Number(value) => value.as_f64().map(Either::B).ok_or_else(|| {
625 HandlerError::Invariant(
626 "boundary key cannot be represented as a JavaScript number".into(),
627 )
628 }),
629 }
630}
631
632fn parse_boundary_mode(mode: Option<&str>) -> napi::Result<BoundaryMode> {
633 match mode {
634 None | Some("final") => Ok(BoundaryMode::Final),
635 Some("updatable") => Ok(BoundaryMode::Updatable),
636 Some(other) => Err(NapiError::from_reason(format!(
637 "unknown boundary mode '{other}'; expected 'final' or 'updatable'"
638 ))),
639 }
640}
641
642fn streaming_error(error: HandlerError) -> NapiError {
643 NapiError::from_reason(error.to_string())
644}
645
646fn decode_protocol(protocol_data: &[u8]) -> napi::Result<HandlerProtocol> {
647 HandlerProtocol::from_protobuf(protocol_data)
648 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))
649}
650
651fn parse_state_json(state_json: &str) -> napi::Result<Value> {
652 serde_json::from_str(state_json)
653 .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))
654}
655
656fn create_handler(plugin: Option<String>) -> napi::Result<WebUIHandler> {
657 let plugin = plugin
658 .map(|value| value.parse::<webui::Plugin>())
659 .transpose()
660 .map_err(NapiError::from_reason)?;
661 Ok(match plugin {
662 Some(webui::Plugin::Fast | webui::Plugin::FastV2) => {
663 WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
664 }
665 Some(webui::Plugin::FastV3) => {
666 WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
667 }
668 Some(webui::Plugin::WebUI) => {
669 WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
670 }
671 None => WebUIHandler::new(),
672 })
673}
674
675webui_handler::define_string_response_writer!(BufferedWriter, output);
676
677fn render_to_string(
678 handler: &WebUIHandler,
679 protocol: &HandlerProtocol,
680 state: &Value,
681 options: &RenderOptions<'_>,
682 capacity: usize,
683) -> napi::Result<String> {
684 let mut writer = BufferedWriter::with_capacity(capacity);
685 handler
686 .render(protocol, state, options, &mut writer)
687 .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
688 Ok(writer.output)
689}
690
691struct CallbackWriter<F> {
693 callback: F,
694 buffer: String,
695 error: Option<NapiError>,
696}
697
698impl<F> CallbackWriter<F>
699where
700 F: FnMut(String) -> napi::Result<()>,
701{
702 fn new(callback: F) -> Self {
703 Self {
704 callback,
705 buffer: String::with_capacity(STREAM_CHUNK_SIZE),
706 error: None,
707 }
708 }
709
710 fn flush(&mut self) -> webui_handler::Result<()> {
711 if self.error.is_some() {
712 return Err(callback_writer_error());
713 }
714 if self.buffer.is_empty() {
715 return Ok(());
716 }
717
718 let chunk = std::mem::replace(&mut self.buffer, String::with_capacity(STREAM_CHUNK_SIZE));
719 if let Err(error) = (self.callback)(chunk) {
720 self.error = Some(error);
721 return Err(callback_writer_error());
722 }
723 Ok(())
724 }
725}
726
727impl<F> ResponseWriter for CallbackWriter<F>
728where
729 F: FnMut(String) -> napi::Result<()>,
730{
731 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
732 if self.error.is_some() {
733 return Err(callback_writer_error());
734 }
735 self.buffer.push_str(content);
736 if self.buffer.len() >= STREAM_CHUNK_SIZE {
737 self.flush()?;
738 }
739 Ok(())
740 }
741
742 fn write_attribute(&mut self, name: &str, value: &str) -> webui_handler::Result<()> {
743 if self.error.is_some() {
744 return Err(callback_writer_error());
745 }
746 webui_handler::append_attribute_to_string(&mut self.buffer, name, value);
747 if self.buffer.len() >= STREAM_CHUNK_SIZE {
748 self.flush()?;
749 }
750 Ok(())
751 }
752
753 fn write_boolean_attribute(&mut self, name: &str) -> webui_handler::Result<()> {
754 if self.error.is_some() {
755 return Err(callback_writer_error());
756 }
757 webui_handler::append_boolean_attribute_to_string(&mut self.buffer, name);
758 if self.buffer.len() >= STREAM_CHUNK_SIZE {
759 self.flush()?;
760 }
761 Ok(())
762 }
763
764 fn end(&mut self) -> webui_handler::Result<()> {
765 self.flush()
766 }
767}
768
769#[cold]
770#[inline(never)]
771fn callback_writer_error() -> HandlerError {
772 HandlerError::Writer("Node chunk callback failed".to_owned())
773}
774
775fn render_to_callback(
776 handler: &WebUIHandler,
777 protocol: &HandlerProtocol,
778 state: &Value,
779 options: &RenderOptions<'_>,
780 on_chunk: &Function<String>,
781) -> napi::Result<()> {
782 let mut writer = CallbackWriter::new(|chunk| on_chunk.call(chunk).map(drop));
783 let render_result = handler.render(protocol, state, options, &mut writer);
784 if let Some(error) = writer.error {
785 return Err(error);
786 }
787 render_result.map_err(|e| NapiError::from_reason(format!("Render error: {e}")))
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793 use std::collections::BTreeMap;
794 use webui_parser::HtmlParser;
795 use webui_protocol::projection_manifest::{
796 ProjectionAdapter, ProjectionComponent, ProjectionManifest, ProjectionProducer,
797 PRODUCER_NAME, SCHEMA_ID,
798 };
799
800 fn build_protocol(html: &str) -> Vec<u8> {
802 let mut parser = HtmlParser::new();
803 parser.parse("index.html", html).expect("parse failed");
804 let tokens = parser.take_tokens();
805 let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
806 protocol.to_protobuf().expect("protobuf encode failed")
807 }
808
809 fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
811 let protocol = HandlerProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
812 let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
813
814 let mut output = String::with_capacity(1024);
815 let handler = WebUIHandler::new();
816
817 struct StringWriter<'a> {
818 output: &'a mut String,
819 }
820 impl ResponseWriter for StringWriter<'_> {
821 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
822 self.output.push_str(content);
823 Ok(())
824 }
825 fn end(&mut self) -> webui_handler::Result<()> {
826 Ok(())
827 }
828 }
829
830 let mut writer = StringWriter {
831 output: &mut output,
832 };
833 handler
834 .render(
835 &protocol,
836 &state,
837 &RenderOptions::new("index.html", "/"),
838 &mut writer,
839 )
840 .map_err(|e| e.to_string())?;
841 Ok(output)
842 }
843
844 #[test]
845 fn render_capacity_hints_isolate_entries_and_routes() {
846 let hints = RenderCapacityHints::new();
847
848 hints.store("index.html", "/contacts", MAX_RENDER_CAPACITY_HINT);
849
850 assert_eq!(
851 hints.load("index.html", "/"),
852 INITIAL_RENDER_CAPACITY,
853 "a small route must not inherit a large route's capacity"
854 );
855 assert_eq!(
856 hints.load("contacts.html", "/contacts"),
857 INITIAL_RENDER_CAPACITY,
858 "another entry must not inherit the large entry's capacity"
859 );
860 assert_eq!(
861 hints.load("index.html", "/contacts"),
862 MAX_RENDER_CAPACITY_HINT
863 );
864 }
865
866 #[test]
867 fn render_capacity_hints_do_not_leak_across_bucket_collisions() {
868 let entry_id = "index.html";
869 let large_path = "/contacts";
870 let large_hash = capacity_hint_hash(entry_id, large_path);
871 let colliding_path = (0..RENDER_CAPACITY_BUCKETS * 16)
872 .map(|index| format!("/route-{index}"))
873 .find(|path| {
874 let hash = capacity_hint_hash(entry_id, path);
875 hash & (RENDER_CAPACITY_BUCKETS - 1) == large_hash & (RENDER_CAPACITY_BUCKETS - 1)
876 && capacity_hint_fingerprint(hash) != capacity_hint_fingerprint(large_hash)
877 })
878 .unwrap_or_else(|| panic!("a same-bucket route should be found"));
879 let hints = RenderCapacityHints::new();
880
881 hints.store(entry_id, large_path, MAX_RENDER_CAPACITY_HINT);
882 assert_eq!(
883 hints.load(entry_id, &colliding_path),
884 INITIAL_RENDER_CAPACITY
885 );
886
887 hints.store(entry_id, &colliding_path, INITIAL_RENDER_CAPACITY * 2);
888 assert_eq!(
889 hints.load(entry_id, large_path),
890 INITIAL_RENDER_CAPACITY,
891 "an overwritten bucket must miss rather than return another route's hint"
892 );
893 assert_eq!(
894 hints.load(entry_id, &colliding_path),
895 INITIAL_RENDER_CAPACITY * 2
896 );
897 }
898
899 #[test]
900 fn render_capacity_hints_clamp_retained_sizes() {
901 let hints = RenderCapacityHints::new();
902
903 hints.store("index.html", "/", 0);
904 assert_eq!(hints.load("index.html", "/"), INITIAL_RENDER_CAPACITY);
905
906 hints.store("index.html", "/", usize::MAX);
907 assert_eq!(hints.load("index.html", "/"), MAX_RENDER_CAPACITY_HINT);
908 }
909
910 #[test]
911 fn learned_render_capacity_avoids_string_growth_without_changing_bytes() {
912 struct ReallocationWriter {
913 output: String,
914 reallocations: usize,
915 }
916
917 impl ResponseWriter for ReallocationWriter {
918 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
919 let previous_capacity = self.output.capacity();
920 self.output.push_str(content);
921 self.reallocations += usize::from(self.output.capacity() != previous_capacity);
922 Ok(())
923 }
924
925 fn end(&mut self) -> webui_handler::Result<()> {
926 Ok(())
927 }
928 }
929
930 fn render_with_capacity(protocol: &HandlerProtocol, capacity: usize) -> (String, usize) {
931 let mut writer = ReallocationWriter {
932 output: String::with_capacity(capacity),
933 reallocations: 0,
934 };
935 WebUIHandler::new()
936 .render(
937 protocol,
938 &Value::Null,
939 &RenderOptions::new("index.html", "/"),
940 &mut writer,
941 )
942 .expect("render should succeed");
943 (writer.output, writer.reallocations)
944 }
945
946 let source = format!("<main>{}</main>", "<p>capacity</p>".repeat(1024));
947 let protocol =
948 HandlerProtocol::from_protobuf(&build_protocol(&source)).expect("protocol should load");
949 let (cold_output, cold_reallocations) =
950 render_with_capacity(&protocol, INITIAL_RENDER_CAPACITY);
951 let (warm_output, warm_reallocations) = render_with_capacity(&protocol, cold_output.len());
952
953 assert!(cold_reallocations > 0);
954 assert_eq!(warm_reallocations, 0);
955 assert_eq!(warm_output, cold_output);
956 }
957
958 #[test]
959 fn test_simple_passthrough() {
960 let proto = build_protocol("<p>Hello</p>");
961 let result = render_to_string(&proto, "{}");
962 assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
963 }
964
965 #[test]
966 fn test_signal_substitution() {
967 let proto = build_protocol("Hello, {{name}}!");
968 let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
969 assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
970 }
971
972 #[test]
973 fn protocol_reuses_decoded_protocol_for_json_state() {
974 let proto = build_protocol("Hello, {{name}}!");
975 let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
976
977 let first = protocol
978 .render(
979 r#"{"name":"First"}"#.to_string(),
980 "index.html".to_string(),
981 "/".to_string(),
982 )
983 .expect("first render should succeed");
984 let second = protocol
985 .render(
986 r#"{"name":"世界"}"#.to_string(),
987 "index.html".to_string(),
988 "/".to_string(),
989 )
990 .expect("second render should succeed");
991
992 assert_eq!(first.as_ref(), b"Hello, First!");
993 assert_eq!(second.as_ref(), "Hello, 世界!".as_bytes());
994 }
995
996 #[test]
997 fn streaming_steps_preserve_key_types_and_checkpoint_segments() {
998 let proto = build_protocol(concat!(
999 "<html><head></head><body>",
1000 r#"<boundary name="first" key="{{firstId}}"><p>{{firstLabel}}</p></boundary>"#,
1001 "<span>between</span>",
1002 r#"<boundary name="second" key="{{secondId}}"><p>{{secondLabel}}</p></boundary>"#,
1003 "<footer>tail</footer>",
1004 "</body></html>",
1005 ));
1006 let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
1007 let mut session = protocol
1008 .stream_response("index.html".to_string(), "/".to_string(), None)
1009 .expect("session should open");
1010 let state = r#"{"firstId":"alpha","firstLabel":"a","secondId":20,"secondLabel":"b"}"#;
1011
1012 let first = session
1013 .start(state.to_string())
1014 .expect("start should discover first boundary");
1015 assert!(!first.done);
1016 assert!(!first.bytes.is_empty());
1017 let first_boundary = first.boundary.expect("first boundary should be returned");
1018 assert_eq!(first_boundary.instance_id, 0);
1019 assert_eq!(first_boundary.declaration_id, 0);
1020 assert_eq!(first_boundary.owner, "index.html");
1021 assert_eq!(first_boundary.name, "first");
1022 assert!(matches!(
1023 first_boundary.key,
1024 Some(Either::A(value)) if value == "alpha"
1025 ));
1026
1027 let resumed = session
1028 .resume(
1029 first_boundary.instance_id,
1030 state.to_string(),
1031 Some("final".to_string()),
1032 )
1033 .expect("resume should commit first boundary");
1034 assert!(!resumed.done);
1035 assert!(resumed.boundary.is_none());
1036 let resumed_text =
1037 std::str::from_utf8(&resumed.bytes).expect("resume output should be UTF-8");
1038 assert!(resumed_text.contains(">a<"));
1039 assert!(!resumed_text.contains("between"));
1040
1041 let next = session
1042 .advance()
1043 .expect("advance should discover second boundary");
1044 assert!(!next.done);
1045 let next_text = std::str::from_utf8(&next.bytes).expect("advance output should be UTF-8");
1046 assert!(next_text.contains("between"));
1047 assert!(!next_text.contains(">b<"));
1048 let second_boundary = next.boundary.expect("second boundary should be returned");
1049 assert_eq!(second_boundary.instance_id, 1);
1050 assert_eq!(second_boundary.declaration_id, 1);
1051 assert_eq!(second_boundary.name, "second");
1052 assert!(matches!(
1053 second_boundary.key,
1054 Some(Either::B(value)) if value == 20.0
1055 ));
1056
1057 let resumed = session
1058 .resume(second_boundary.instance_id, state.to_string(), None)
1059 .expect("resume should commit second boundary");
1060 assert!(!resumed.done);
1061 assert!(resumed.boundary.is_none());
1062 let resumed_text =
1063 std::str::from_utf8(&resumed.bytes).expect("resume output should be UTF-8");
1064 assert!(resumed_text.contains(">b<"));
1065 assert!(!resumed_text.contains("tail"));
1066
1067 let done = session.advance().expect("final advance should complete");
1068 assert!(done.done);
1069 assert!(done.boundary.is_none());
1070 assert!(std::str::from_utf8(&done.bytes)
1071 .expect("advance output should be UTF-8")
1072 .contains("tail"));
1073 }
1074
1075 #[test]
1076 fn streaming_update_returns_buffer_for_updatable_occurrence() {
1077 let proto = build_protocol(concat!(
1078 "<html><head></head><body>",
1079 r#"<boundary name="first"><p>{{count}}</p></boundary>"#,
1080 r#"<boundary name="second"><p>done</p></boundary>"#,
1081 "</body></html>",
1082 ));
1083 let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
1084 let mut session = protocol
1085 .stream_response("index.html".to_string(), "/".to_string(), None)
1086 .expect("session should open");
1087 let first = session
1088 .start(r#"{"count":1}"#.to_string())
1089 .expect("start should discover first boundary")
1090 .boundary
1091 .expect("first boundary should be returned");
1092 let resumed = session
1093 .resume(
1094 first.instance_id,
1095 r#"{"count":1}"#.to_string(),
1096 Some("updatable".to_string()),
1097 )
1098 .expect("resume should commit updatable boundary");
1099 assert!(!resumed.done);
1100 assert!(resumed.boundary.is_none());
1101
1102 let update = session
1103 .update(first.instance_id, r#"{"count":2}"#.to_string())
1104 .expect("update should render");
1105 assert!(!update.is_empty());
1106 let update_text = std::str::from_utf8(&update).expect("update should be UTF-8");
1107 assert!(update_text.contains(r#""count":2"#));
1108
1109 let second = session
1110 .advance()
1111 .expect("advance should discover second boundary")
1112 .boundary
1113 .expect("second boundary should be returned");
1114 let resumed = session
1115 .resume(second.instance_id, "{}".to_string(), None)
1116 .expect("second resume should commit boundary");
1117 assert!(!resumed.done);
1118 assert!(resumed.boundary.is_none());
1119 let done = session.advance().expect("final advance should complete");
1120 assert!(done.done);
1121 }
1122
1123 #[test]
1124 fn streaming_advance_rejects_out_of_order_calls() {
1125 let proto = build_protocol(concat!(
1126 "<html><head></head><body>",
1127 r#"<boundary name="first"><p>first</p></boundary>"#,
1128 "</body></html>",
1129 ));
1130 let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
1131 let mut session = protocol
1132 .stream_response("index.html".to_string(), "/".to_string(), None)
1133 .expect("session should open");
1134
1135 let before_start = session
1136 .advance()
1137 .err()
1138 .expect("advance before start should fail");
1139 assert!(before_start
1140 .to_string()
1141 .contains("start must be called before this operation"));
1142
1143 let start = session
1144 .start("{}".to_string())
1145 .expect("start should succeed");
1146 let before_resume = session
1147 .advance()
1148 .err()
1149 .expect("advance before resume should fail");
1150 assert!(before_resume
1151 .to_string()
1152 .contains("there is no committed boundary to advance past"));
1153
1154 let boundary = start.boundary.expect("first boundary should be returned");
1155 session
1156 .resume(boundary.instance_id, "{}".to_string(), None)
1157 .expect("resume should still succeed after rejected advance");
1158 assert!(session.advance().expect("advance should complete").done);
1159 }
1160
1161 #[test]
1162 fn streaming_start_returns_done_for_boundary_free_document() {
1163 let proto = build_protocol("<html><head></head><body><p>done</p></body></html>");
1164 let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
1165 let mut session = protocol
1166 .stream_response("index.html".to_string(), "/".to_string(), None)
1167 .expect("session should open");
1168
1169 let step = session
1170 .start("{}".to_string())
1171 .expect("boundary-free start should complete");
1172 assert!(step.done);
1173 assert!(step.boundary.is_none());
1174 assert!(std::str::from_utf8(&step.bytes)
1175 .expect("output should be UTF-8")
1176 .contains("<p>done</p>"));
1177 }
1178
1179 #[test]
1180 fn test_for_loop() {
1181 let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
1182 let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
1183 assert_eq!(
1184 result.as_deref(),
1185 Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
1186 );
1187 }
1188
1189 #[test]
1190 fn test_if_condition_true() {
1191 let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
1192 let result = render_to_string(&proto, r#"{"show": true}"#);
1193 assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
1194 }
1195
1196 #[test]
1197 fn test_if_condition_false() {
1198 let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
1199 let result = render_to_string(&proto, r#"{"show": false}"#);
1200 assert_eq!(result.as_deref(), Ok(""));
1201 }
1202
1203 #[test]
1204 fn test_html_escaping() {
1205 let proto = build_protocol("<div>{{content}}</div>");
1206 let state = r#"{"content": "<script>alert('xss')</script>"}"#;
1207 let result = render_to_string(&proto, state).expect("render should succeed");
1208 assert!(!result.contains("<script>"));
1209 assert!(result.contains("<script>"));
1210 }
1211
1212 #[test]
1213 fn test_raw_signal() {
1214 let proto = build_protocol("<div>{{{content}}}</div>");
1215 let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
1216 assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
1217 }
1218
1219 #[test]
1220 fn test_invalid_json() {
1221 let proto = build_protocol("<p>hi</p>");
1222 let result = render_to_string(&proto, "NOT JSON");
1223 assert!(result.is_err());
1224 }
1225
1226 #[test]
1227 fn test_empty_state() {
1228 let proto = build_protocol("<p>static</p>");
1229 let result = render_to_string(&proto, "{}");
1230 assert_eq!(result.as_deref(), Ok("<p>static</p>"));
1231 }
1232
1233 #[test]
1234 fn test_nested_object_signal() {
1235 let proto = build_protocol("{{user.name}}");
1236 let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
1237 assert_eq!(result.as_deref(), Ok("Alice"));
1238 }
1239
1240 #[test]
1241 fn test_invalid_protobuf() {
1242 let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
1243 assert!(result.is_err());
1244 }
1245
1246 fn build_projected_protocol(html: &str, schema: &[&str]) -> Vec<u8> {
1248 let mut parser = HtmlParser::new();
1249 parser.parse("index.html", html).expect("parse failed");
1250 let tokens = parser.take_tokens();
1251 let mut protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
1252 protocol.fragments.insert(
1253 "client-card".to_string(),
1254 webui_protocol::FragmentList {
1255 fragments: vec![webui_protocol::WebUIFragment::raw("<p>client</p>")],
1256 contains_boundary: false,
1257 },
1258 );
1259 protocol
1260 .fragments
1261 .get_mut("index.html")
1262 .expect("index fragment should exist")
1263 .fragments
1264 .insert(1, webui_protocol::WebUIFragment::component("client-card"));
1265 protocol.initial_state_strategy = webui_protocol::InitialStateStrategy::Components as i32;
1266 protocol.components.insert(
1267 "client-card".to_string(),
1268 webui_protocol::ComponentData {
1269 hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
1270 hydration_keys: schema.iter().map(|key| (*key).to_string()).collect(),
1271 ..Default::default()
1272 },
1273 );
1274 protocol.to_protobuf().expect("protobuf encode failed")
1275 }
1276
1277 fn render_with_webui_plugin(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
1281 let protocol = HandlerProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
1282 let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
1283
1284 let mut output = String::with_capacity(1024);
1285 struct StringWriter<'a> {
1286 output: &'a mut String,
1287 }
1288 impl ResponseWriter for StringWriter<'_> {
1289 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
1290 self.output.push_str(content);
1291 Ok(())
1292 }
1293 fn end(&mut self) -> webui_handler::Result<()> {
1294 Ok(())
1295 }
1296 }
1297 let mut writer = StringWriter {
1298 output: &mut output,
1299 };
1300 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1301 handler
1302 .render(
1303 &protocol,
1304 &state,
1305 &RenderOptions::new("index.html", "/"),
1306 &mut writer,
1307 )
1308 .map_err(|e| e.to_string())?;
1309 Ok(output)
1310 }
1311
1312 #[test]
1313 fn render_projects_state_to_component_hydration_keys() {
1314 let bytes =
1317 build_projected_protocol("<html><body><p>{{kept}}</p></body></html>", &["kept"]);
1318 let out =
1319 render_with_webui_plugin(&bytes, r#"{"kept":"KEPT_VALUE","dropped":"DROPPED_VALUE"}"#)
1320 .expect("render should succeed");
1321
1322 assert!(
1324 out.contains(r#""kept":"KEPT_VALUE""#),
1325 "hydratable key missing from bootstrap state: {out}"
1326 );
1327 assert!(
1329 !out.contains("DROPPED_VALUE"),
1330 "server-only value leaked: {out}"
1331 );
1332 assert!(
1333 !out.contains("dropped"),
1334 "server-only key name leaked: {out}"
1335 );
1336 }
1337
1338 fn projection_manifest_json() -> String {
1341 const EMPTY_SHA256: &str =
1342 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1343 let mut manifest = ProjectionManifest {
1344 schema: SCHEMA_ID.to_string(),
1345 producer: ProjectionProducer {
1346 name: PRODUCER_NAME.to_string(),
1347 version: "0.0.18".to_string(),
1348 },
1349 adapter: ProjectionAdapter {
1350 name: "test".to_string(),
1351 bundler: "test@1.0.0".to_string(),
1352 },
1353 root: ".".to_string(),
1354 analysis_hash: format!("sha256:{}", "1".repeat(64)),
1355 build_id: String::new(),
1356 inputs: BTreeMap::from([("demo-card.ts".to_string(), EMPTY_SHA256.to_string())]),
1357 outputs: BTreeMap::from([("bundle.js".to_string(), EMPTY_SHA256.to_string())]),
1358 components: BTreeMap::from([(
1359 "demo-card".to_string(),
1360 ProjectionComponent {
1361 module: "demo-card.ts".to_string(),
1362 outputs: vec!["bundle.js".to_string()],
1363 hydration_keys: vec!["name".to_string()],
1364 navigation_keys: vec!["label".to_string(), "name".to_string()],
1365 },
1366 )]),
1367 entry_closures: BTreeMap::new(),
1368 };
1369 manifest.build_id = manifest.compute_build_id();
1370 serde_json::to_string(&manifest).unwrap()
1371 }
1372
1373 fn projection_build_options(app_dir: &std::path::Path) -> JsBuildOptions {
1374 JsBuildOptions {
1375 app_dir: app_dir.to_string_lossy().to_string(),
1376 entry: None,
1377 css: None,
1378 dom: None,
1379 css_bundle: None,
1380 plugin: Some("webui".to_string()),
1381 components: None,
1382 component_asset_roots: None,
1383 metafile: None,
1384 css_file_name_template: None,
1385 css_public_base: None,
1386 legal_comments: None,
1387 theme: None,
1388 projection_manifests: None,
1389 projection_manifest_objects: None,
1390 }
1391 }
1392
1393 #[test]
1394 fn test_build_accepts_projection_paths_and_inline_objects() {
1395 let dir = tempfile::TempDir::new().unwrap();
1396 std::fs::write(dir.path().join("index.html"), "<demo-card></demo-card>").unwrap();
1397 std::fs::write(dir.path().join("demo-card.html"), "<p>{{name}}</p>").unwrap();
1398 std::fs::write(dir.path().join("demo-card.ts"), "").unwrap();
1399 std::fs::write(dir.path().join("bundle.js"), "").unwrap();
1400 let manifest_path = dir.path().join("projection.json");
1401 let json = projection_manifest_json();
1402 std::fs::write(&manifest_path, &json).unwrap();
1403
1404 let mut path_options = projection_build_options(dir.path());
1405 path_options.projection_manifests = Some(vec![manifest_path.to_string_lossy().to_string()]);
1406 let path_result = build(path_options).unwrap();
1407 let path_protocol = WebUIProtocol::from_protobuf(&path_result.protocol).unwrap();
1408 assert_eq!(
1409 path_protocol.components["demo-card"].hydration_keys,
1410 ["name"]
1411 );
1412
1413 std::fs::remove_file(&manifest_path).unwrap();
1414 let mut inline_options = projection_build_options(dir.path());
1415 inline_options.projection_manifest_objects = Some(vec![JsProjectionManifest {
1416 path: manifest_path.to_string_lossy().to_string(),
1417 json,
1418 }]);
1419 let inline_result = build(inline_options).unwrap();
1420 let inline_protocol = WebUIProtocol::from_protobuf(&inline_result.protocol).unwrap();
1421 assert_eq!(
1422 inline_protocol.components["demo-card"].navigation_keys,
1423 ["label", "name"]
1424 );
1425 }
1426
1427 #[test]
1428 fn test_build_simple_app() {
1429 let dir = tempfile::TempDir::new().unwrap();
1430 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1431
1432 let options = JsBuildOptions {
1433 app_dir: dir.path().to_string_lossy().to_string(),
1434 entry: None,
1435 css: None,
1436 dom: None,
1437 css_bundle: None,
1438 plugin: None,
1439 components: None,
1440 component_asset_roots: None,
1441 metafile: None,
1442 css_file_name_template: None,
1443 css_public_base: None,
1444 legal_comments: None,
1445 theme: None,
1446 projection_manifests: None,
1447 projection_manifest_objects: None,
1448 };
1449
1450 let result = build(options).unwrap();
1451 assert!(!result.protocol.is_empty());
1452 assert!(result.stats.fragment_count > 0);
1453 assert!(result.stats.protocol_size_bytes > 0);
1454 assert!(result.stats.duration_ms >= 0.0);
1455 }
1456
1457 #[test]
1458 fn test_build_with_custom_entry() {
1459 let dir = tempfile::TempDir::new().unwrap();
1460 std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
1461
1462 let options = JsBuildOptions {
1463 app_dir: dir.path().to_string_lossy().to_string(),
1464 entry: Some("page.html".to_string()),
1465 css: None,
1466 dom: None,
1467 css_bundle: None,
1468 plugin: None,
1469 components: None,
1470 component_asset_roots: None,
1471 metafile: None,
1472 css_file_name_template: None,
1473 css_public_base: None,
1474 legal_comments: None,
1475 theme: None,
1476 projection_manifests: None,
1477 projection_manifest_objects: None,
1478 };
1479
1480 let result = build(options).unwrap();
1481 assert!(!result.protocol.is_empty());
1482 }
1483
1484 #[test]
1485 fn test_build_missing_app_dir() {
1486 let options = JsBuildOptions {
1487 app_dir: "/nonexistent/path".to_string(),
1488 entry: None,
1489 css: None,
1490 dom: None,
1491 css_bundle: None,
1492 plugin: None,
1493 components: None,
1494 component_asset_roots: None,
1495 metafile: None,
1496 css_file_name_template: None,
1497 css_public_base: None,
1498 legal_comments: None,
1499 theme: None,
1500 projection_manifests: None,
1501 projection_manifest_objects: None,
1502 };
1503
1504 let result = build(options);
1505 assert!(result.is_err());
1506 }
1507
1508 #[test]
1509 fn test_build_invalid_css() {
1510 let dir = tempfile::TempDir::new().unwrap();
1511 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1512
1513 let options = JsBuildOptions {
1514 app_dir: dir.path().to_string_lossy().to_string(),
1515 entry: None,
1516 css: Some("bogus".to_string()),
1517 dom: None,
1518 css_bundle: None,
1519 plugin: None,
1520 components: None,
1521 component_asset_roots: None,
1522 metafile: None,
1523 css_file_name_template: None,
1524 css_public_base: None,
1525 legal_comments: None,
1526 theme: None,
1527 projection_manifests: None,
1528 projection_manifest_objects: None,
1529 };
1530
1531 let result = build(options);
1532 assert!(result.is_err());
1533 }
1534
1535 #[test]
1536 fn test_build_invalid_dom_strategy() {
1537 let dir = tempfile::TempDir::new().unwrap();
1538 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1539
1540 let options = JsBuildOptions {
1541 app_dir: dir.path().to_string_lossy().to_string(),
1542 entry: None,
1543 css: None,
1544 dom: Some("closed".to_string()),
1545 css_bundle: None,
1546 plugin: None,
1547 components: None,
1548 component_asset_roots: None,
1549 metafile: None,
1550 css_file_name_template: None,
1551 css_public_base: None,
1552 legal_comments: None,
1553 theme: None,
1554 projection_manifests: None,
1555 projection_manifest_objects: None,
1556 };
1557
1558 assert!(build(options).is_err());
1559 }
1560
1561 #[test]
1562 fn test_build_with_components_css() {
1563 let dir = tempfile::TempDir::new().unwrap();
1564 std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
1565 std::fs::write(dir.path().join("my-card.html"), "<div>content</div>").unwrap();
1566 std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
1567
1568 let options = JsBuildOptions {
1569 app_dir: dir.path().to_string_lossy().to_string(),
1570 entry: None,
1571 css: Some("link".to_string()),
1572 dom: None,
1573 css_bundle: None,
1574 plugin: None,
1575 components: None,
1576 component_asset_roots: None,
1577 metafile: None,
1578 css_file_name_template: None,
1579 css_public_base: None,
1580 legal_comments: None,
1581 theme: None,
1582 projection_manifests: None,
1583 projection_manifest_objects: None,
1584 };
1585
1586 let result = build(options).unwrap();
1587 assert_eq!(result.css_files.len(), 2);
1589 assert_eq!(result.css_files[0], "my-card.css");
1590 assert!(result.css_files[1].contains("color: red"));
1591 assert_eq!(result.stats.css_file_count, 1);
1592 }
1593
1594 #[test]
1595 fn test_build_with_theme_missing_token_errors() {
1596 let dir = tempfile::TempDir::new().unwrap();
1597 std::fs::write(dir.path().join("index.html"), "<my-card></my-card>").unwrap();
1598 std::fs::write(dir.path().join("my-card.html"), "<div>Card</div>").unwrap();
1599 std::fs::write(
1600 dir.path().join("my-card.css"),
1601 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
1602 )
1603 .unwrap();
1604 let theme_path = dir.path().join("theme.json");
1605 std::fs::write(&theme_path, r#"{"themes":{"light":{"token-b":"green"}}}"#).unwrap();
1606
1607 let options = JsBuildOptions {
1608 app_dir: dir.path().to_string_lossy().to_string(),
1609 entry: None,
1610 css: Some("link".to_string()),
1611 dom: None,
1612 css_bundle: None,
1613 plugin: None,
1614 components: None,
1615 component_asset_roots: None,
1616 metafile: None,
1617 css_file_name_template: None,
1618 css_public_base: None,
1619 legal_comments: None,
1620 theme: Some(theme_path.to_string_lossy().to_string()),
1621 projection_manifests: None,
1622 projection_manifest_objects: None,
1623 };
1624
1625 let Err(err) = build(options) else {
1626 panic!("missing theme token must fail");
1627 };
1628 let message = err.to_string();
1629 assert!(message.contains("missing-theme-token"), "msg: {message}");
1630 assert!(message.contains("--token-c"), "msg: {message}");
1631 }
1632
1633 #[test]
1634 fn test_build_returns_component_asset_files() {
1635 let dir = tempfile::TempDir::new().unwrap();
1636 std::fs::write(dir.path().join("index.html"), "<app-shell></app-shell>").unwrap();
1637 std::fs::write(dir.path().join("app-shell.html"), "<div></div>").unwrap();
1638 std::fs::write(dir.path().join("lazy-panel.html"), "<p>{{title}}</p>").unwrap();
1639 std::fs::write(dir.path().join("lazy-panel.ts"), "export {};").unwrap();
1640
1641 let options = JsBuildOptions {
1642 app_dir: dir.path().to_string_lossy().to_string(),
1643 entry: None,
1644 css: Some("link".to_string()),
1645 dom: None,
1646 css_bundle: None,
1647 plugin: Some("webui".to_string()),
1648 components: None,
1649 component_asset_roots: Some(vec!["lazy-panel".to_string()]),
1650 metafile: Some(true),
1651 css_file_name_template: None,
1652 css_public_base: None,
1653 legal_comments: None,
1654 theme: None,
1655 projection_manifests: None,
1656 projection_manifest_objects: None,
1657 };
1658
1659 let result = build(options).unwrap();
1660
1661 assert_eq!(result.component_asset_files.len(), 2);
1662 assert_eq!(result.component_asset_files[0], "lazy-panel.webui.js");
1663 assert!(result.component_asset_files[1].contains("webui-component-asset"));
1664 assert!(result.component_asset_files[1].contains("export default asset;"));
1665 let metafile = result
1666 .metafile
1667 .as_deref()
1668 .expect("requested metafile must be returned");
1669 let parsed: serde_json::Value = serde_json::from_str(metafile).unwrap();
1670 assert!(parsed["inputs"].get("webui:component/lazy-panel").is_some());
1671 assert!(parsed["outputs"].get("lazy-panel.webui.js").is_some());
1672 }
1673
1674 #[test]
1675 fn test_build_legal_comments_none_strips_legal_css() {
1676 let dir = tempfile::TempDir::new().unwrap();
1677 std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
1678 std::fs::write(
1679 dir.path().join("my-card.html"),
1680 r#"<template shadowrootmode="open"><div>content</div></template>"#,
1681 )
1682 .unwrap();
1683 std::fs::write(
1684 dir.path().join("my-card.css"),
1685 "/*! @license MIT */ .card { color: red; }",
1686 )
1687 .unwrap();
1688
1689 let options = JsBuildOptions {
1690 app_dir: dir.path().to_string_lossy().to_string(),
1691 entry: None,
1692 css: Some("link".to_string()),
1693 dom: None,
1694 css_bundle: None,
1695 plugin: None,
1696 components: None,
1697 component_asset_roots: None,
1698 metafile: None,
1699 css_file_name_template: None,
1700 css_public_base: None,
1701 legal_comments: Some("none".to_string()),
1702 theme: None,
1703 projection_manifests: None,
1704 projection_manifest_objects: None,
1705 };
1706
1707 let result = build(options).unwrap();
1708 assert_eq!(result.css_files[1], " .card { color: red; }");
1709 }
1710
1711 #[test]
1712 fn test_build_invalid_legal_comments() {
1713 let dir = tempfile::TempDir::new().unwrap();
1714 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1715
1716 let options = JsBuildOptions {
1717 app_dir: dir.path().to_string_lossy().to_string(),
1718 entry: None,
1719 css: None,
1720 dom: None,
1721 css_bundle: None,
1722 plugin: None,
1723 components: None,
1724 component_asset_roots: None,
1725 metafile: None,
1726 css_file_name_template: None,
1727 css_public_base: None,
1728 legal_comments: Some("linked".to_string()),
1729 theme: None,
1730 projection_manifests: None,
1731 projection_manifest_objects: None,
1732 };
1733
1734 let result = build(options);
1735 assert!(result.is_err());
1736 }
1737
1738 #[test]
1739 fn test_build_with_unwrapped_component_defaults_to_shadow() {
1740 let dir = tempfile::TempDir::new().unwrap();
1741 std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1742 std::fs::write(dir.path().join("my-card.html"), "<div>content</div>").unwrap();
1743
1744 let options = JsBuildOptions {
1745 app_dir: dir.path().to_string_lossy().to_string(),
1746 entry: None,
1747 css: None,
1748 dom: None,
1749 css_bundle: None,
1750 plugin: None,
1751 components: None,
1752 component_asset_roots: None,
1753 metafile: None,
1754 css_file_name_template: None,
1755 css_public_base: None,
1756 legal_comments: None,
1757 theme: None,
1758 projection_manifests: None,
1759 projection_manifest_objects: None,
1760 };
1761
1762 let result = build(options).unwrap();
1763 let json = inspect(result.protocol).unwrap();
1764 assert!(json.contains("shadowrootmode"));
1765 }
1766
1767 #[test]
1768 fn test_build_with_light_dom_omits_generated_shadow_root() {
1769 let dir = tempfile::TempDir::new().unwrap();
1770 std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1771 std::fs::write(dir.path().join("my-card.html"), "<div>content</div>").unwrap();
1772
1773 let options = JsBuildOptions {
1774 app_dir: dir.path().to_string_lossy().to_string(),
1775 entry: None,
1776 css: None,
1777 dom: Some("light".to_string()),
1778 css_bundle: None,
1779 plugin: None,
1780 components: None,
1781 component_asset_roots: None,
1782 metafile: None,
1783 css_file_name_template: None,
1784 css_public_base: None,
1785 legal_comments: None,
1786 theme: None,
1787 projection_manifests: None,
1788 projection_manifest_objects: None,
1789 };
1790
1791 let result = build(options).unwrap();
1792 let json = inspect(result.protocol).unwrap();
1793 assert!(!json.contains("shadowrootmode"));
1794 }
1795
1796 #[test]
1797 fn test_build_with_authored_shadow_dom_preserves_shadow_root_template() {
1798 let dir = tempfile::TempDir::new().unwrap();
1799 std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1800 std::fs::write(
1801 dir.path().join("my-card.html"),
1802 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
1803 )
1804 .unwrap();
1805
1806 let options = JsBuildOptions {
1807 app_dir: dir.path().to_string_lossy().to_string(),
1808 entry: None,
1809 css: None,
1810 dom: None,
1811 css_bundle: None,
1812 plugin: None,
1813 components: None,
1814 component_asset_roots: None,
1815 metafile: None,
1816 css_file_name_template: None,
1817 css_public_base: None,
1818 legal_comments: None,
1819 theme: None,
1820 projection_manifests: None,
1821 projection_manifest_objects: None,
1822 };
1823
1824 let result = build(options).unwrap();
1825 let json = inspect(result.protocol).unwrap();
1826 assert!(
1827 json.contains("shadowrootmode"),
1828 "authored Shadow DOM wrapper should be preserved, got: {json}"
1829 );
1830 }
1831
1832 #[test]
1833 fn test_build_rejects_invalid_shadow_wrapper() {
1834 let dir = tempfile::TempDir::new().unwrap();
1835 std::fs::write(dir.path().join("index.html"), "<my-card></my-card>").unwrap();
1836 std::fs::write(
1837 dir.path().join("my-card.html"),
1838 r#"<template shadowrootmode="closed"><p>invalid</p></template>"#,
1839 )
1840 .unwrap();
1841
1842 let options = JsBuildOptions {
1843 app_dir: dir.path().to_string_lossy().to_string(),
1844 entry: None,
1845 css: None,
1846 dom: None,
1847 css_bundle: None,
1848 plugin: None,
1849 components: None,
1850 component_asset_roots: None,
1851 metafile: None,
1852 css_file_name_template: None,
1853 css_public_base: None,
1854 legal_comments: None,
1855 theme: None,
1856 projection_manifests: None,
1857 projection_manifest_objects: None,
1858 };
1859
1860 let result = build(options);
1861 assert!(result.is_err());
1862 }
1863
1864 #[test]
1865 fn test_inspect_valid_protocol() {
1866 let proto = build_protocol("<h1>Hello {{name}}</h1>");
1867 let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
1868 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1869 assert!(parsed.get("fragments").is_some());
1870 }
1871
1872 #[test]
1873 fn test_inspect_invalid_protocol() {
1874 let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
1875 assert!(result.is_err());
1876 }
1877
1878 #[test]
1879 fn test_protocol_tokens_empty() {
1880 let proto = build_protocol("<p>Hello</p>");
1881 let protocol = Protocol::new(Buffer::from(proto), None).unwrap();
1882 let tokens = protocol.tokens();
1883 assert!(tokens.is_empty());
1884 }
1885
1886 #[test]
1887 fn test_protocol_tokens_returns_parsed_tokens() {
1888 let mut parser = HtmlParser::new();
1890 parser.parse("index.html", "<p>Hi</p>").expect("parse");
1891 let protocol = WebUIProtocol::with_tokens(
1892 parser.into_fragment_records(),
1893 vec![
1894 "colorBrandBackground".to_string(),
1895 "fontSizeBase300".to_string(),
1896 ],
1897 );
1898 let proto = protocol.to_protobuf().expect("encode");
1899 let protocol = Protocol::new(Buffer::from(proto), None).unwrap();
1900 let tokens = protocol.tokens();
1901 assert_eq!(tokens, vec!["colorBrandBackground", "fontSizeBase300"]);
1902 }
1903
1904 #[test]
1905 fn test_protocol_tokens_invalid_protobuf() {
1906 let result = Protocol::new(Buffer::from(vec![0xFF, 0xFF]), None);
1907 assert!(result.is_err());
1908 }
1909}