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, 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
27/// A writer that batches rendered fragments before crossing into JavaScript.
28struct 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/// A decoded protocol with reusable indices for repeated WASM renders.
117#[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    /// Decode protobuf bytes once for repeated rendering.
127    #[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    /// Render from an existing JSON string.
141    #[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    /// Stream from an existing JSON string in bounded chunks.
152    #[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    /// Produce a complete partial-navigation response.
168    #[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    /// Return component template payloads for requested component tags.
182    #[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    /// Return CSS token names in build order.
202    #[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    /// Open a host-driven progressive response for a streaming entry.
209    ///
210    /// Unlike `renderStream`, which pushes every chunk through one callback
211    /// during a single synchronous call, the returned session hands each chunk
212    /// back so the host owns the socket, the write order, and backpressure.
213    #[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/// A progressive HTML response driven one semantic step at a time from JavaScript.
238///
239/// `start()`, `resume()`, and `advance()` return
240/// `{ bytes, done, boundary? }`, where `bytes` is a `Uint8Array` and a boundary is
241/// `{ instanceId, declarationId, owner, name, key }`. Boundary keys retain
242/// their authored JSON type: strings are JavaScript strings and finite numbers
243/// are JavaScript numbers.
244///
245/// ```js
246/// const session = protocol.streamResponse('index.html', '/');
247/// let step = session.start(JSON.stringify(shellState));
248/// controller.enqueue(step.bytes);
249/// while (!step.done) {
250///   const { instanceId, name, key } = step.boundary;
251///   const state = await loadBoundary(name, key);
252///   step = session.resume(instanceId, JSON.stringify(state), 'updatable');
253///   controller.enqueue(step.bytes);
254///   step = session.advance();
255///   controller.enqueue(step.bytes);
256/// }
257/// ```
258#[wasm_bindgen]
259pub struct StreamingSession {
260    inner: HandlerStreamingSession,
261}
262
263#[wasm_bindgen]
264impl StreamingSession {
265    /// Render until the first runtime boundary occurrence or terminal.
266    #[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    /// Commit the pending occurrence through its checkpoint, then stop.
274    ///
275    /// `mode` is `"final"` (default) or `"updatable"`. Only updatable
276    /// boundaries accept later `update()` calls.
277    #[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    /// Write the parent bytes after the committed occurrence.
294    ///
295    /// Valid only after `resume()`. Returns the next boundary occurrence or
296    /// completes the document tail.
297    #[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    /// Push a projected state patch to a committed updatable boundary.
304    #[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        // Only the hydratable key reaches the bootstrap state block...
669        assert!(
670            rendered.contains(r#""kept":"KEPT_VALUE_WASM""#),
671            "hydratable key missing from bootstrap state:\n{rendered}"
672        );
673        // ...the non-hydratable key is projected out entirely.
674        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}