1use crate::error::WasmError;
7use js_sys::{Function, Object, Reflect, Uint8Array};
8use serde_json::Value;
9use std::sync::Arc;
10use wasm_bindgen::prelude::*;
11use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
12use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
13use webui_handler::plugin::webui::WebUIHydrationPlugin;
14use webui_handler::{
15 BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, HandlerError,
16 Protocol as HandlerProtocol, RenderCapacityHints, RenderOptions, ResponseWriter,
17 SessionOptions, StreamStep as HandlerStreamStep, StreamingSession as HandlerStreamingSession,
18 WebUIHandler,
19};
20#[cfg(test)]
21use webui_protocol::WebUIProtocol;
22
23const STREAM_CHUNK_SIZE: usize = 16 * 1024;
24
25webui_handler::define_string_response_writer!(StringWriter, content);
26
27struct CallbackWriter<'a> {
29 on_chunk: &'a Function,
30 buffer: String,
31}
32
33impl<'a> CallbackWriter<'a> {
34 fn new(on_chunk: &'a Function) -> Self {
35 Self {
36 on_chunk,
37 buffer: String::with_capacity(STREAM_CHUNK_SIZE),
38 }
39 }
40
41 fn flush(&mut self) -> webui_handler::Result<()> {
42 if self.buffer.is_empty() {
43 return Ok(());
44 }
45
46 let chunk = std::mem::replace(&mut self.buffer, String::with_capacity(STREAM_CHUNK_SIZE));
47 self.on_chunk
48 .call1(&JsValue::UNDEFINED, &JsValue::from_str(&chunk))
49 .map(|_| ())
50 .map_err(|error| HandlerError::Writer(format!("{error:?}")))
51 }
52}
53
54impl ResponseWriter for CallbackWriter<'_> {
55 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
56 self.buffer.push_str(content);
57 if self.buffer.len() >= STREAM_CHUNK_SIZE {
58 self.flush()?;
59 }
60 Ok(())
61 }
62
63 fn write_attribute(&mut self, name: &str, value: &str) -> webui_handler::Result<()> {
64 webui_handler::append_attribute_to_string(&mut self.buffer, name, value);
65 if self.buffer.len() >= STREAM_CHUNK_SIZE {
66 self.flush()?;
67 }
68 Ok(())
69 }
70
71 fn write_boolean_attribute(&mut self, name: &str) -> webui_handler::Result<()> {
72 webui_handler::append_boolean_attribute_to_string(&mut self.buffer, name);
73 if self.buffer.len() >= STREAM_CHUNK_SIZE {
74 self.flush()?;
75 }
76 Ok(())
77 }
78
79 fn end(&mut self) -> webui_handler::Result<()> {
80 self.flush()
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub(crate) enum HandlerPluginKind {
86 FastV2,
87 FastV3,
88 WebUI,
89}
90
91impl HandlerPluginKind {
92 fn parse(name: &str) -> Result<Self, WasmError> {
93 match name {
94 "fast" | "fast-v2" => Ok(Self::FastV2),
95 "fast-v3" => Ok(Self::FastV3),
96 "webui" => Ok(Self::WebUI),
97 other => Err(WasmError::UnknownPlugin(other.to_string())),
98 }
99 }
100}
101
102struct WasmRenderOptions {
103 entry: String,
104 request_path: String,
105}
106
107impl Default for WasmRenderOptions {
108 fn default() -> Self {
109 Self {
110 entry: "index.html".to_string(),
111 request_path: "/".to_string(),
112 }
113 }
114}
115
116#[wasm_bindgen]
118pub struct Protocol {
119 inner: Arc<HandlerProtocol>,
120 handler: Arc<WebUIHandler>,
121 output_capacity_hints: RenderCapacityHints,
122}
123
124#[wasm_bindgen]
125impl Protocol {
126 #[wasm_bindgen(constructor)]
128 pub fn new(protocol_bytes: &[u8], plugin: Option<String>) -> Result<Protocol, JsValue> {
129 let plugin = parse_optional_plugin(plugin.as_deref())
130 .map_err(|error| JsValue::from_str(&error.to_string()))?;
131 let inner = HandlerProtocol::from_protobuf(protocol_bytes)
132 .map_err(|error| JsValue::from_str(&format!("Protocol error: {error}")))?;
133 Ok(Self {
134 inner: Arc::new(inner),
135 handler: Arc::new(create_handler(plugin)),
136 output_capacity_hints: RenderCapacityHints::new(),
137 })
138 }
139
140 #[wasm_bindgen(js_name = render)]
142 pub fn render(&self, state_json: &str, options: Option<Object>) -> Result<String, JsValue> {
143 let options =
144 parse_render_options(options).map_err(|error| JsValue::from_str(&error.to_string()))?;
145 let state =
146 parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))?;
147 self.render_value(&state, &options)
148 .map_err(|error| JsValue::from_str(&error.to_string()))
149 }
150
151 #[wasm_bindgen(js_name = renderStream)]
153 pub fn render_stream(
154 &self,
155 state_json: &str,
156 on_chunk: &Function,
157 options: Option<Object>,
158 ) -> Result<(), JsValue> {
159 let options =
160 parse_render_options(options).map_err(|error| JsValue::from_str(&error.to_string()))?;
161 let state =
162 parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))?;
163 render_protocol_to_callback_value(&self.handler, &self.inner, &state, &options, on_chunk)
164 .map_err(|error| JsValue::from_str(&error.to_string()))
165 }
166
167 #[wasm_bindgen(js_name = renderPartial)]
169 pub fn render_partial(
170 &self,
171 state_json: &str,
172 entry_id: &str,
173 request_path: &str,
174 inventory_hex: &str,
175 ) -> Result<String, JsValue> {
176 self.inner
177 .render_partial_json(state_json, entry_id, request_path, inventory_hex)
178 .map_err(|error| JsValue::from_str(&format!("render_partial failed: {error}")))
179 }
180
181 #[wasm_bindgen(js_name = renderComponentTemplates)]
183 pub fn render_component_templates(
184 &self,
185 component_tags: JsValue,
186 inventory_hex: &str,
187 ) -> Result<String, JsValue> {
188 let tags: Vec<String> = serde_wasm_bindgen::from_value(component_tags)
189 .map_err(|error| JsValue::from_str(&format!("invalid component tags: {error}")))?;
190 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
191 let result = self
192 .inner
193 .render_component_templates(&tag_refs, inventory_hex)
194 .map_err(|error| {
195 JsValue::from_str(&format!("render_component_templates failed: {error}"))
196 })?;
197 serde_json::to_string(&result)
198 .map_err(|error| JsValue::from_str(&format!("JSON serialize error: {error}")))
199 }
200
201 #[wasm_bindgen(js_name = tokens)]
203 pub fn tokens(&self) -> Result<JsValue, JsValue> {
204 serde_wasm_bindgen::to_value(self.inner.tokens())
205 .map_err(|error| JsValue::from_str(&format!("Serialization error: {error}")))
206 }
207
208 #[wasm_bindgen(js_name = streamResponse)]
214 pub fn stream_response(
215 &self,
216 entry: String,
217 request_path: String,
218 options: Option<Object>,
219 ) -> Result<StreamingSession, JsValue> {
220 let mut session_options = SessionOptions::new(entry, request_path);
221 if let Some(options) = options {
222 session_options.nonce = optional_string_property(&options, "nonce")?;
223 session_options.head_inject = optional_string_property(&options, "headInject")?;
224 session_options.body_inject = optional_string_property(&options, "bodyInject")?;
225 }
226
227 HandlerStreamingSession::new(
228 Arc::clone(&self.handler),
229 Arc::clone(&self.inner),
230 session_options,
231 )
232 .map(|inner| StreamingSession { inner })
233 .map_err(streaming_error)
234 }
235}
236
237#[wasm_bindgen]
259pub struct StreamingSession {
260 inner: HandlerStreamingSession,
261}
262
263#[wasm_bindgen]
264impl StreamingSession {
265 #[wasm_bindgen(js_name = start)]
267 pub fn start(&mut self, state_json: &str) -> Result<Object, JsValue> {
268 let state = session_state(state_json)?;
269 let step = self.inner.start(state).map_err(streaming_error)?;
270 stream_step_object(step)
271 }
272
273 #[wasm_bindgen(js_name = resume)]
278 pub fn resume(
279 &mut self,
280 instance_id: u32,
281 state_json: &str,
282 mode: Option<String>,
283 ) -> Result<Object, JsValue> {
284 let state = session_state(state_json)?;
285 let mode = parse_boundary_mode(mode.as_deref())?;
286 let step = self
287 .inner
288 .resume(BoundaryInstanceId::from_raw(instance_id), state, mode)
289 .map_err(streaming_error)?;
290 stream_step_object(step)
291 }
292
293 #[wasm_bindgen(js_name = advance)]
298 pub fn advance(&mut self) -> Result<Object, JsValue> {
299 let step = self.inner.advance().map_err(streaming_error)?;
300 stream_step_object(step)
301 }
302
303 #[wasm_bindgen(js_name = update)]
305 pub fn update(&mut self, instance_id: u32, patch_json: &str) -> Result<Vec<u8>, JsValue> {
306 let patch = session_state(patch_json)?;
307
308 self.inner
309 .update(BoundaryInstanceId::from_raw(instance_id), &patch)
310 .map_err(streaming_error)
311 }
312}
313
314fn stream_step_object(step: HandlerStreamStep) -> Result<Object, JsValue> {
315 let result = Object::new();
316 let bytes = Uint8Array::from(step.bytes.as_slice());
317 set_object_property(&result, "bytes", bytes.as_ref())?;
318 set_object_property(&result, "done", &JsValue::from_bool(step.done))?;
319 if let Some(boundary) = step.boundary {
320 let boundary = boundary_object(boundary)?;
321 set_object_property(&result, "boundary", boundary.as_ref())?;
322 }
323 Ok(result)
324}
325
326fn boundary_object(boundary: BoundaryDescriptor) -> Result<Object, JsValue> {
327 let result = Object::new();
328 set_object_property(
329 &result,
330 "instanceId",
331 &JsValue::from_f64(f64::from(boundary.instance_id.raw())),
332 )?;
333 set_object_property(
334 &result,
335 "declarationId",
336 &JsValue::from_f64(f64::from(boundary.declaration_id)),
337 )?;
338 set_object_property(&result, "owner", &JsValue::from_str(&boundary.owner))?;
339 set_object_property(&result, "name", &JsValue::from_str(&boundary.name))?;
340 if let Some(key) = boundary.key {
341 set_object_property(&result, "key", &boundary_key_value(key)?)?;
342 }
343 Ok(result)
344}
345
346fn boundary_key_value(key: BoundaryKey) -> Result<JsValue, JsValue> {
347 match key {
348 BoundaryKey::String(value) => Ok(JsValue::from_str(&value)),
349 BoundaryKey::Number(value) => value.as_f64().map(JsValue::from_f64).ok_or_else(|| {
350 JsValue::from_str("boundary key cannot be represented as a JavaScript number")
351 }),
352 }
353}
354
355fn set_object_property(object: &Object, key: &str, value: &JsValue) -> Result<(), JsValue> {
356 let written = Reflect::set(object.as_ref(), &JsValue::from_str(key), value)
357 .map_err(|_| JsValue::from_str(&format!("failed to set StreamStep '{key}' property")))?;
358 if written {
359 Ok(())
360 } else {
361 Err(JsValue::from_str(&format!(
362 "failed to set StreamStep '{key}' property"
363 )))
364 }
365}
366
367fn session_state(state_json: &str) -> Result<Value, JsValue> {
368 parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))
369}
370
371fn parse_boundary_mode(mode: Option<&str>) -> Result<BoundaryMode, JsValue> {
372 match mode {
373 None | Some("final") => Ok(BoundaryMode::Final),
374 Some("updatable") => Ok(BoundaryMode::Updatable),
375 Some(other) => Err(JsValue::from_str(&format!(
376 "unknown boundary mode '{other}'; expected 'final' or 'updatable'"
377 ))),
378 }
379}
380
381fn streaming_error(error: HandlerError) -> JsValue {
382 JsValue::from_str(&error.to_string())
383}
384
385fn optional_string_property(options: &Object, key: &str) -> Result<Option<String>, JsValue> {
386 let value = Reflect::get(options, &JsValue::from_str(key))
387 .map_err(|_| JsValue::from_str(&format!("failed to read '{key}' option")))?;
388 if value.is_undefined() || value.is_null() {
389 return Ok(None);
390 }
391 value
392 .as_string()
393 .map(Some)
394 .ok_or_else(|| JsValue::from_str(&format!("'{key}' must be a string")))
395}
396
397#[cfg(test)]
398pub(crate) fn render_protocol_to_string(
399 protocol: &WebUIProtocol,
400 state_json: &str,
401 entry: &str,
402 request_path: &str,
403 plugin: Option<HandlerPluginKind>,
404) -> Result<String, WasmError> {
405 let state = parse_state_json(state_json)?;
406 let options = WasmRenderOptions {
407 entry: entry.to_string(),
408 request_path: request_path.to_string(),
409 };
410 let protocol = Protocol {
411 inner: Arc::new(HandlerProtocol::new(protocol.clone())),
412 handler: Arc::new(create_handler(plugin)),
413 output_capacity_hints: RenderCapacityHints::new(),
414 };
415 protocol.render_value(&state, &options)
416}
417
418fn parse_state_json(state_json: &str) -> Result<Value, WasmError> {
419 serde_json::from_str(state_json).map_err(WasmError::State)
420}
421
422impl Protocol {
423 fn render_value(
424 &self,
425 state: &Value,
426 options: &WasmRenderOptions,
427 ) -> Result<String, WasmError> {
428 let capacity = self
429 .output_capacity_hints
430 .load(&options.entry, &options.request_path);
431 let mut writer = StringWriter::with_capacity(capacity);
432 self.handler.render(
433 &self.inner,
434 state,
435 &RenderOptions::new(&options.entry, &options.request_path),
436 &mut writer,
437 )?;
438 self.output_capacity_hints.store(
439 &options.entry,
440 &options.request_path,
441 writer.content.len(),
442 );
443 Ok(writer.content)
444 }
445}
446
447fn render_protocol_to_callback_value(
448 handler: &WebUIHandler,
449 protocol: &HandlerProtocol,
450 state: &Value,
451 options: &WasmRenderOptions,
452 on_chunk: &Function,
453) -> Result<(), WasmError> {
454 let mut writer = CallbackWriter::new(on_chunk);
455 handler.render(
456 protocol,
457 state,
458 &RenderOptions::new(&options.entry, &options.request_path),
459 &mut writer,
460 )?;
461 writer.flush()?;
462 Ok(())
463}
464
465pub(crate) fn parse_optional_plugin(
466 plugin: Option<&str>,
467) -> Result<Option<HandlerPluginKind>, WasmError> {
468 plugin.map(HandlerPluginKind::parse).transpose()
469}
470
471fn parse_render_options(options: Option<Object>) -> Result<WasmRenderOptions, WasmError> {
472 let mut parsed = WasmRenderOptions::default();
473 let Some(options) = options else {
474 return Ok(parsed);
475 };
476
477 if let Some(entry) = optional_string_field(options.as_ref(), "entry")? {
478 parsed.entry = entry;
479 }
480 if let Some(request_path) = optional_string_field(options.as_ref(), "requestPath")? {
481 parsed.request_path = request_path;
482 }
483 Ok(parsed)
484}
485
486fn optional_string_field(options: &JsValue, field: &str) -> Result<Option<String>, WasmError> {
487 let value = Reflect::get(options, &JsValue::from_str(field)).map_err(|_| {
488 WasmError::InvalidOptions(format!("failed to read `{field}` from options object"))
489 })?;
490 if value.is_null() || value.is_undefined() {
491 return Ok(None);
492 }
493 value.as_string().map(Some).ok_or_else(|| {
494 WasmError::InvalidOptions(format!("`{field}` must be a string when provided"))
495 })
496}
497
498fn create_handler(plugin: Option<HandlerPluginKind>) -> WebUIHandler {
499 match plugin {
500 Some(HandlerPluginKind::FastV2) => {
501 WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
502 }
503 Some(HandlerPluginKind::FastV3) => {
504 WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
505 }
506 Some(HandlerPluginKind::WebUI) => {
507 WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
508 }
509 None => WebUIHandler::new(),
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516
517 fn structural_fragment(value: &str) -> webui_protocol::WebUIFragment {
518 let mut token = String::with_capacity("}}}webui:".len() + value.len());
519 token.push_str("}}}webui:");
520 token.push_str(value);
521 webui_protocol::WebUIFragment::signal(token, true)
522 }
523
524 #[test]
525 fn parse_plugin_keeps_fast_aliases_parser_free() {
526 assert_eq!(
527 parse_optional_plugin(Some("fast")).unwrap(),
528 Some(HandlerPluginKind::FastV2)
529 );
530 assert_eq!(
531 parse_optional_plugin(Some("fast-v2")).unwrap(),
532 Some(HandlerPluginKind::FastV2)
533 );
534 assert_eq!(
535 parse_optional_plugin(Some("fast-v3")).unwrap(),
536 Some(HandlerPluginKind::FastV3)
537 );
538 assert_eq!(
539 parse_optional_plugin(Some("webui")).unwrap(),
540 Some(HandlerPluginKind::WebUI)
541 );
542 }
543
544 #[test]
545 fn parse_plugin_rejects_unknown_names() {
546 let err = parse_optional_plugin(Some("unknown")).unwrap_err();
547 assert_eq!(
548 err.to_string(),
549 "Unknown plugin: unknown. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
550 );
551 }
552
553 #[test]
554 fn protocol_reuses_decoded_protocol() {
555 use std::collections::HashMap;
556 use webui_protocol::{FragmentList, WebUIFragment};
557
558 let mut fragments = HashMap::new();
559 fragments.insert(
560 "index.html".to_string(),
561 FragmentList {
562 fragments: vec![WebUIFragment::signal("name".to_string(), true)],
563 contains_boundary: false,
564 },
565 );
566 let bytes = WebUIProtocol::new(fragments)
567 .to_protobuf()
568 .expect("protocol should serialize");
569 let protocol = Protocol::new(&bytes, None).expect("protocol should load");
570
571 let first = protocol
572 .render(r#"{"name":"first"}"#, None)
573 .expect("first render should succeed");
574 let second = protocol
575 .render(r#"{"name":"second"}"#, None)
576 .expect("second render should succeed");
577
578 assert_eq!(first, "first");
579 assert_eq!(second, "second");
580 }
581
582 #[test]
583 fn protocol_render_learns_route_output_capacity() {
584 use std::collections::HashMap;
585 use webui_protocol::{FragmentList, WebUIFragment};
586
587 let mut fragments = HashMap::new();
588 fragments.insert(
589 "index.html".to_string(),
590 FragmentList {
591 fragments: vec![
592 WebUIFragment::raw("<p>capacity</p>".repeat(1024)),
593 WebUIFragment::signal("name".to_string(), true),
594 ],
595 contains_boundary: false,
596 },
597 );
598 let bytes = WebUIProtocol::new(fragments)
599 .to_protobuf()
600 .expect("protocol should serialize");
601 let protocol = Protocol::new(&bytes, None).expect("protocol should load");
602 let cold_capacity = protocol.output_capacity_hints.load("index.html", "/");
603
604 let first = protocol
605 .render(r#"{"name":"first"}"#, None)
606 .expect("first render should succeed");
607 let second = protocol
608 .render(r#"{"name":"first"}"#, None)
609 .expect("second render should succeed");
610
611 assert!(first.len() > cold_capacity);
612 assert_eq!(
613 protocol.output_capacity_hints.load("index.html", "/"),
614 first.len()
615 );
616 assert_eq!(second, first);
617 }
618
619 #[test]
620 fn render_projects_state_to_component_hydration_keys() {
621 use std::collections::HashMap;
622 use webui_protocol::{
623 ComponentData, FragmentList, InitialStateStrategy, StateProjectionMode, WebUIFragment,
624 };
625
626 let mut fragments = HashMap::new();
627 fragments.insert(
628 "index.html".to_string(),
629 FragmentList {
630 fragments: vec![
631 WebUIFragment::raw("<html><head>"),
632 structural_fragment("head_end"),
633 WebUIFragment::raw("</head><body>"),
634 WebUIFragment::component("client-card"),
635 structural_fragment("body_end"),
636 WebUIFragment::raw("</body></html>"),
637 ],
638 contains_boundary: false,
639 },
640 );
641 fragments.insert(
642 "client-card".to_string(),
643 FragmentList {
644 fragments: vec![WebUIFragment::raw("<p>client</p>")],
645 contains_boundary: false,
646 },
647 );
648 let mut protocol = WebUIProtocol::new(fragments);
649 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
650 protocol.components.insert(
651 "client-card".to_string(),
652 ComponentData {
653 hydration_mode: StateProjectionMode::Keys as i32,
654 hydration_keys: vec!["kept".to_string()],
655 ..Default::default()
656 },
657 );
658
659 let rendered = render_protocol_to_string(
660 &protocol,
661 r#"{"kept":"KEPT_VALUE_WASM","dropped":"DROPPED_VALUE_WASM"}"#,
662 "index.html",
663 "/",
664 Some(HandlerPluginKind::WebUI),
665 )
666 .expect("render should succeed");
667
668 assert!(
670 rendered.contains(r#""kept":"KEPT_VALUE_WASM""#),
671 "hydratable key missing from bootstrap state:\n{rendered}"
672 );
673 assert!(
675 !rendered.contains("DROPPED_VALUE_WASM"),
676 "server-only value leaked into render:\n{rendered}"
677 );
678 assert!(
679 !rendered.contains("dropped"),
680 "server-only key name leaked into render:\n{rendered}"
681 );
682 }
683
684 #[cfg(feature = "parser")]
685 mod streaming_tests {
686 use std::collections::HashMap;
687
688 use super::*;
689
690 fn session(html: &str) -> HandlerStreamingSession {
691 let files = HashMap::from([("index.html".to_string(), html.to_string())]);
692 let protocol = crate::parser::parse_to_protocol(&files, "index.html", &[])
693 .expect("protocol should parse");
694 HandlerStreamingSession::new(
695 Arc::new(WebUIHandler::new()),
696 Arc::new(HandlerProtocol::new(protocol)),
697 SessionOptions::new("index.html", "/"),
698 )
699 .expect("session should open")
700 }
701
702 fn state(json: &str) -> Value {
703 parse_state_json(json).expect("state should parse")
704 }
705
706 #[test]
707 fn streaming_steps_preserve_key_types_and_checkpoint_segments() {
708 let mut session = session(concat!(
709 "<html><head></head><body>",
710 r#"<boundary name="first" key="{{firstId}}"><p>{{firstLabel}}</p></boundary>"#,
711 "<span>between</span>",
712 r#"<boundary name="second" key="{{secondId}}"><p>{{secondLabel}}</p></boundary>"#,
713 "<footer>tail</footer>",
714 "</body></html>",
715 ));
716 let state =
717 state(r#"{"firstId":"alpha","firstLabel":"a","secondId":20,"secondLabel":"b"}"#);
718
719 let first = session
720 .start(&state)
721 .expect("start should discover first boundary");
722 assert!(!first.done);
723 assert!(!first.bytes.is_empty());
724 let first_boundary = first.boundary.expect("first boundary should be returned");
725 assert_eq!(first_boundary.instance_id.raw(), 0);
726 assert_eq!(first_boundary.declaration_id, 0);
727 assert_eq!(first_boundary.owner.as_ref(), "index.html");
728 assert_eq!(first_boundary.name.as_ref(), "first");
729 assert_eq!(
730 first_boundary.key,
731 Some(BoundaryKey::String("alpha".to_string()))
732 );
733
734 let resumed = session
735 .resume(first_boundary.instance_id, &state, BoundaryMode::Final)
736 .expect("resume should commit first boundary");
737 assert!(!resumed.done);
738 assert!(resumed.boundary.is_none());
739 let resumed_text =
740 std::str::from_utf8(&resumed.bytes).expect("resume output should be UTF-8");
741 assert!(resumed_text.contains(">a<"));
742 assert!(!resumed_text.contains("between"));
743
744 let next = session
745 .advance()
746 .expect("advance should discover second boundary");
747 assert!(!next.done);
748 let next_text =
749 std::str::from_utf8(&next.bytes).expect("advance output should be UTF-8");
750 assert!(next_text.contains("between"));
751 assert!(!next_text.contains(">b<"));
752 let second_boundary = next.boundary.expect("second boundary should be returned");
753 assert_eq!(second_boundary.instance_id.raw(), 1);
754 assert_eq!(second_boundary.declaration_id, 1);
755 assert_eq!(second_boundary.name.as_ref(), "second");
756 assert_eq!(second_boundary.key, Some(BoundaryKey::Number(20.into())));
757
758 let resumed = session
759 .resume(second_boundary.instance_id, &state, BoundaryMode::Final)
760 .expect("resume should commit second boundary");
761 assert!(!resumed.done);
762 assert!(resumed.boundary.is_none());
763 let resumed_text =
764 std::str::from_utf8(&resumed.bytes).expect("resume output should be UTF-8");
765 assert!(resumed_text.contains(">b<"));
766 assert!(!resumed_text.contains("tail"));
767
768 let done = session.advance().expect("final advance should complete");
769 assert!(done.done);
770 assert!(done.boundary.is_none());
771 assert!(std::str::from_utf8(&done.bytes)
772 .expect("advance output should be UTF-8")
773 .contains("tail"));
774 }
775
776 #[test]
777 fn streaming_update_returns_bytes_for_updatable_occurrence() {
778 let mut session = session(concat!(
779 "<html><head></head><body>",
780 r#"<boundary name="first"><p>{{count}}</p></boundary>"#,
781 r#"<boundary name="second"><p>done</p></boundary>"#,
782 "</body></html>",
783 ));
784 let initial = state(r#"{"count":1}"#);
785 let first = session
786 .start(&initial)
787 .expect("start should discover first boundary")
788 .boundary
789 .expect("first boundary should be returned");
790 let resumed = session
791 .resume(first.instance_id, &initial, BoundaryMode::Updatable)
792 .expect("resume should commit updatable boundary");
793 assert!(!resumed.done);
794 assert!(resumed.boundary.is_none());
795
796 let update = session
797 .update(first.instance_id, &state(r#"{"count":2}"#))
798 .expect("update should render");
799 assert!(!update.is_empty());
800 assert!(std::str::from_utf8(&update)
801 .expect("update should be UTF-8")
802 .contains(r#""count":2"#));
803
804 let second = session
805 .advance()
806 .expect("advance should discover second boundary")
807 .boundary
808 .expect("second boundary should be returned");
809 let resumed = session
810 .resume(second.instance_id, &state("{}"), BoundaryMode::Final)
811 .expect("second resume should commit boundary");
812 assert!(!resumed.done);
813 assert!(resumed.boundary.is_none());
814 let done = session.advance().expect("final advance should complete");
815 assert!(done.done);
816 }
817
818 #[test]
819 fn streaming_advance_rejects_out_of_order_calls() {
820 let mut session = session(concat!(
821 "<html><head></head><body>",
822 r#"<boundary name="first"><p>first</p></boundary>"#,
823 "</body></html>",
824 ));
825
826 let before_start = session
827 .advance()
828 .expect_err("advance before start should fail");
829 assert!(before_start
830 .to_string()
831 .contains("start must be called before this operation"));
832
833 let start = session.start(&state("{}")).expect("start should succeed");
834 let before_resume = session
835 .advance()
836 .expect_err("advance before resume should fail");
837 assert!(before_resume
838 .to_string()
839 .contains("there is no committed boundary to advance past"));
840
841 let boundary = start.boundary.expect("first boundary should be returned");
842 session
843 .resume(boundary.instance_id, &state("{}"), BoundaryMode::Final)
844 .expect("resume should still succeed after rejected advance");
845 assert!(session.advance().expect("advance should complete").done);
846 }
847
848 #[test]
849 fn streaming_start_returns_done_for_boundary_free_document() {
850 let mut session = session("<html><head></head><body><p>done</p></body></html>");
851
852 let step = session
853 .start(&state("{}"))
854 .expect("boundary-free start should complete");
855 assert!(step.done);
856 assert!(step.boundary.is_none());
857 assert!(std::str::from_utf8(&step.bytes)
858 .expect("output should be UTF-8")
859 .contains("<p>done</p>"));
860 }
861 }
862}