Skip to main content

webui_handler/streaming/
mod.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Progressive streaming hydration (`WebUIHandler::render_streaming`).
5//!
6//! An opt-in render mode in which each compile-time `<boundary>` commits an
7//! independently hydratable island while the response is still open, instead
8//! of deferring every island to one page-wide `#webui-data` block at
9//! `body_end`. See DESIGN.md, "Progressive Streaming Hydration"
10//! for the normative contract.
11//!
12//! The split mirrors the lifecycle of a single boundary:
13//!
14//! - [`state`] holds the request-local render state and the `head_start`
15//!   precondition.
16//! - [`root`] marks streamed SSR hosts so they defer until their boundary
17//!   commits.
18//! - [`inventory`] tracks what each checkpoint rendered and can reach.
19//! - [`checkpoint`] writes the boundary envelope.
20//! - [`error`] holds the cold diagnostic constructors.
21
22mod checkpoint;
23mod error;
24mod inventory;
25mod owned;
26mod plan;
27mod root;
28mod session;
29mod state;
30
31use serde_json::Value;
32
33use crate::route_handler::Protocol;
34use crate::{
35    FlushWriter, HandlerError, RenderOptions, ResponseWriter, Result, WebUIHandler,
36    WebUIProcessContext,
37};
38
39pub(crate) use error::streaming_boundary_error;
40pub(crate) use inventory::{record_checkpoint_tag, streaming_template_already_sent};
41pub use owned::{BufferSink, SessionOptions, StreamingSession};
42pub(crate) use plan::{PreparedStreamingEntryPlan, StreamingEntryPlan};
43pub(crate) use root::{
44    consume_streaming_component_root, ensure_no_pending_streaming_root,
45    prepare_generated_streaming_root, validate_pending_streaming_root,
46    validate_streaming_root_opening, ComponentHostOrigin,
47};
48pub use session::{BoundaryId, BoundaryMode, StreamingResponse};
49pub(crate) use state::{validate_streaming_head_start, StreamingRenderState};
50
51use crate::write_usize;
52use error::parse_boundary_sequence;
53use root::process_streaming_root_signal;
54use state::{
55    increment_streaming_boundary_id, increment_streaming_record_sequence,
56    require_streaming_head_start, BOUNDARY_END_PREFIX, BOUNDARY_START_PREFIX,
57};
58
59pub(crate) const STREAMING_MARKER: &str = "<meta name=\"webui-streaming\" content=\"1\">";
60
61/// Request-local streaming sink selected once at
62/// [`WebUIHandler::render_streaming`] entry.
63///
64/// Wrapping the transport maps the internal streaming flush hook to the
65/// caller's concrete [`FlushWriter`] with no second virtual dispatch.
66struct StreamingSink<'w, W: FlushWriter + ?Sized> {
67    transport: &'w mut W,
68}
69
70impl<W: FlushWriter + ?Sized> ResponseWriter for StreamingSink<'_, W> {
71    fn write(&mut self, content: &str) -> Result<()> {
72        self.transport.write(content)
73    }
74
75    fn end(&mut self) -> Result<()> {
76        self.transport.end()
77    }
78
79    fn stream_flush(&mut self) -> Result<()> {
80        self.transport.flush()
81    }
82}
83
84pub(super) fn streaming_state<'a, 'data>(
85    context: &'a mut WebUIProcessContext<'data, '_, '_>,
86) -> Result<&'a mut StreamingRenderState<'data>> {
87    context.streaming.as_deref_mut().ok_or_else(|| {
88        HandlerError::Invariant("streaming signal processed outside a streaming render".to_string())
89    })
90}
91
92pub(super) fn flush_streaming_transport(
93    context: &mut WebUIProcessContext<'_, '_, '_>,
94) -> Result<()> {
95    if context.streaming.is_none() {
96        return Err(HandlerError::Invariant(
97            "streaming flush requested outside a streaming render".to_string(),
98        ));
99    }
100    // The sink resets its own dirty flag as part of flushing, so there is no
101    // separate `dirty.set(false)` here.
102    context.writer.stream_flush()
103}
104
105impl WebUIHandler {
106    pub(crate) fn process_streaming_signal<'data>(
107        &self,
108        value: &'data str,
109        context: &mut WebUIProcessContext<'data, '_, '_>,
110    ) -> Result<bool> {
111        if context
112            .streaming
113            .as_ref()
114            .is_some_and(|streaming| streaming.body_ended)
115        {
116            return Err(streaming_boundary_error(
117                value,
118                "structural signal arrived after the body_end terminal record",
119            ));
120        }
121
122        if value == "head_start" {
123            if context
124                .streaming
125                .as_ref()
126                .is_some_and(|streaming| streaming.head_marker_emitted)
127            {
128                return Err(HandlerError::DuplicateStreamingHeadStart);
129            }
130            context.writer.write(STREAMING_MARKER)?;
131            streaming_state(context)?.head_marker_emitted = true;
132            return Ok(true);
133        }
134
135        if value.starts_with("streaming_root") {
136            // Compiler-owned streamed SSR root marker: inject ` data-ws` inside
137            // the component's opening tag (the parser placed this signal before
138            // `>`). The host must live inside an open boundary, otherwise it can
139            // never be activated — fail with a cold structured error.
140            process_streaming_root_signal(value, context)?;
141            return Ok(true);
142        }
143        if let Some(raw_sequence) = value.strip_prefix(BOUNDARY_START_PREFIX) {
144            require_streaming_head_start(context, "streaming boundary")?;
145            let sequence = parse_boundary_sequence(value, raw_sequence)?;
146            let streaming = streaming_state(context)?;
147            if let Some(active) = streaming.active_boundary {
148                return Err(streaming_boundary_error(
149                    value,
150                    &format!("nested boundary {sequence}; boundary {active} is still open"),
151                ));
152            }
153            if sequence != streaming.next_boundary_id {
154                return Err(streaming_boundary_error(
155                    value,
156                    &format!(
157                        "expected boundary sequence {}, received {sequence}",
158                        streaming.next_boundary_id
159                    ),
160                ));
161            }
162            context.writer.write("<!--wb:")?;
163            write_usize(context.writer, sequence)?;
164            context.writer.write("-->")?;
165            streaming_state(context)?.active_boundary = Some(sequence);
166            return Ok(true);
167        }
168        if value.starts_with("boundary_start") {
169            require_streaming_head_start(context, "streaming boundary")?;
170            return Err(streaming_boundary_error(
171                value,
172                "expected `boundary_start:<decimal sequence>`",
173            ));
174        }
175
176        if let Some(raw_sequence) = value.strip_prefix(BOUNDARY_END_PREFIX) {
177            require_streaming_head_start(context, "streaming boundary")?;
178            let sequence = parse_boundary_sequence(value, raw_sequence)?;
179            let active = streaming_state(context)?.active_boundary.ok_or_else(|| {
180                streaming_boundary_error(value, "boundary end has no matching start")
181            })?;
182            if active != sequence {
183                return Err(streaming_boundary_error(
184                    value,
185                    &format!("boundary {active} is open, but boundary {sequence} ended"),
186                ));
187            }
188
189            context.writer.write("<!--/wb:")?;
190            write_usize(context.writer, sequence)?;
191            context.writer.write("-->")?;
192            let (record_sequence, updatable) = {
193                let streaming = streaming_state(context)?;
194                (
195                    streaming.next_record_sequence,
196                    streaming.checkpoint_updatable,
197                )
198            };
199            self.emit_streaming_checkpoint(record_sequence, sequence, updatable, context)?;
200            let streaming = streaming_state(context)?;
201            streaming.active_boundary = None;
202            streaming.checkpoint_updatable = false;
203            increment_streaming_boundary_id(value, streaming)?;
204            increment_streaming_record_sequence(value, streaming)?;
205            return Ok(true);
206        }
207        if value.starts_with("boundary_end") {
208            require_streaming_head_start(context, "streaming boundary")?;
209            return Err(streaming_boundary_error(
210                value,
211                "expected `boundary_end:<decimal sequence>`",
212            ));
213        }
214
215        if value == "body_end" {
216            require_streaming_head_start(context, "body_end")?;
217            let (active_boundary, body_ended, next_record_sequence) = {
218                let streaming = streaming_state(context)?;
219                (
220                    streaming.active_boundary,
221                    streaming.body_ended,
222                    streaming.next_record_sequence,
223                )
224            };
225            if let Some(active) = active_boundary {
226                return Err(streaming_boundary_error(
227                    value,
228                    &format!("body ended while boundary {active} was still open"),
229                ));
230            }
231            if body_ended {
232                return Err(streaming_boundary_error(value, "duplicate body_end signal"));
233            }
234            if let Some(html) = context.body_inject {
235                context.writer.write(html)?;
236            }
237
238            // The terminal flush commits any raw/native tail bytes. Registered
239            // component hosts cannot appear outside an explicit boundary, so a
240            // rootless tail never needs another template or state projection.
241            // Keeping the envelope empty also prevents whitespace or a body
242            // injection from re-sending complete state on legacy protocols.
243            self.emit_streaming_terminal(next_record_sequence, context)?;
244            let streaming = streaming_state(context)?;
245            streaming.body_ended = true;
246            increment_streaming_record_sequence(value, streaming)?;
247            context.body_end_emitted = true;
248            return Ok(true);
249        }
250
251        if value == "head_end" {
252            require_streaming_head_start(context, "head_end")?;
253        } else if value == "body_start" {
254            require_streaming_head_start(context, "body_start")?;
255        }
256
257        Ok(false)
258    }
259
260    /// Render an opt-in progressive hydration response.
261    ///
262    /// Boundary signals are validated in document order and every committed
263    /// checkpoint is flushed through `writer`. Unlike [`Self::render`], this
264    /// mode emits boundary envelopes instead of a page-wide `#webui-data`
265    /// block and requires structural `head_start` and `body_end` signals.
266    pub fn render_streaming<'a, W: FlushWriter + ?Sized>(
267        &self,
268        protocol: &'a Protocol,
269        state: &Value,
270        options: &RenderOptions<'a>,
271        writer: &'a mut W,
272    ) -> Result<()> {
273        let mut response = self.stream_response(protocol, options, writer)?;
274        response.write_shell_buffered(state)?;
275        let boundary_count = response.boundary_count();
276        for boundary in 0..boundary_count {
277            let id = BoundaryId::from_index(boundary)?;
278            response.write_boundary(id, state, BoundaryMode::Final)?;
279        }
280        response.finish(state)
281    }
282}