Skip to main content

webui_wasm/
handler.rs

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