Skip to main content

webui_handler/streaming/
owned.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Host-owned progressive sessions with pull-based byte delivery.
5
6use std::sync::Arc;
7
8use serde_json::Value;
9
10use super::session::{
11    BoundaryDescriptor, BoundaryInstanceId, BoundaryMode, SessionCall, SessionCore, StreamStatus,
12    StreamingState,
13};
14use super::StreamingSink;
15use crate::route_handler::Protocol;
16use crate::{FlushWriter, RenderOptions, ResponseWriter, Result, WebUIHandler};
17
18/// Reusable byte sink for host-owned sessions.
19///
20/// Records where the streaming transport flushed so a session can prove each
21/// returned step ends on a real flush boundary rather than silently merging a
22/// checkpoint with the bytes that follow it.
23#[derive(Default)]
24pub struct BufferSink {
25    bytes: Vec<u8>,
26    last_flush: usize,
27}
28
29impl BufferSink {
30    fn reset(&mut self) {
31        self.bytes.clear();
32        self.last_flush = 0;
33    }
34}
35
36impl ResponseWriter for BufferSink {
37    fn write(&mut self, content: &str) -> Result<()> {
38        self.bytes.extend_from_slice(content.as_bytes());
39        Ok(())
40    }
41
42    fn write_attribute(&mut self, name: &str, value: &str) -> Result<()> {
43        crate::append_attribute_to_bytes(&mut self.bytes, name, value);
44        Ok(())
45    }
46
47    fn write_boolean_attribute(&mut self, name: &str) -> Result<()> {
48        crate::append_boolean_attribute_to_bytes(&mut self.bytes, name);
49        Ok(())
50    }
51
52    fn end(&mut self) -> Result<()> {
53        Ok(())
54    }
55}
56
57impl FlushWriter for BufferSink {
58    fn flush(&mut self) -> Result<()> {
59        self.last_flush = self.bytes.len();
60        Ok(())
61    }
62}
63
64/// Owned per-response configuration.
65#[derive(Clone, Debug)]
66pub struct SessionOptions {
67    /// Entry fragment to render.
68    pub entry_id: String,
69    /// Request path used for route matching.
70    pub request_path: String,
71    /// Optional CSP nonce.
72    pub nonce: Option<String>,
73    /// Optional trusted HTML injected at head_end.
74    pub head_inject: Option<String>,
75    /// Optional trusted HTML injected at body_end.
76    pub body_inject: Option<String>,
77}
78
79impl SessionOptions {
80    /// Create options for an entry and request path.
81    #[must_use]
82    pub fn new(entry_id: impl Into<String>, request_path: impl Into<String>) -> Self {
83        Self {
84            entry_id: entry_id.into(),
85            request_path: request_path.into(),
86            nonce: None,
87            head_inject: None,
88            body_inject: None,
89        }
90    }
91}
92
93/// Bytes and continuation state returned by one owned semantic step.
94///
95/// The bytes are one semantic write segment ending on a transport flush
96/// boundary: the shell prefix, exactly one committed occurrence, the parent
97/// bytes between two occurrences, or the tail plus terminal. A host writes them
98/// with a single `write` + `flush`.
99#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct StreamStep {
101    /// Complete bytes produced by this call.
102    pub bytes: Vec<u8>,
103    /// Next runtime occurrence waiting for resume.
104    pub boundary: Option<BoundaryDescriptor>,
105    /// True after terminal emission.
106    pub done: bool,
107}
108
109/// A progressive response owned independently of any Rust borrow.
110///
111/// Exposes the same step machine as [`super::StreamingResponse`]:
112/// [`Self::start`] writes the shell prefix, [`Self::resume`] writes exactly one
113/// occurrence through its checkpoint, and [`Self::advance`] writes the parent
114/// bytes that follow it.
115pub struct StreamingSession {
116    handler: Arc<WebUIHandler>,
117    protocol: Arc<Protocol>,
118    options: SessionOptions,
119    core: SessionCore,
120    sink: BufferSink,
121}
122
123impl StreamingSession {
124    /// Create an unstarted owned session.
125    pub fn new(
126        handler: Arc<WebUIHandler>,
127        protocol: Arc<Protocol>,
128        options: SessionOptions,
129    ) -> Result<Self> {
130        let core = SessionCore::new(&handler, &protocol, &options.entry_id)?;
131        Ok(Self {
132            handler,
133            protocol,
134            options,
135            core,
136            sink: BufferSink::default(),
137        })
138    }
139
140    /// Render until the first runtime boundary occurrence or terminal.
141    ///
142    /// Pass an owned [`Value`] to move it into the continuation, or a reference
143    /// to retain caller ownership and clone only the required projection.
144    pub fn start<'state>(
145        &mut self,
146        state: impl Into<StreamingState<'state>>,
147    ) -> Result<StreamStep> {
148        match state.into() {
149            StreamingState::Borrowed(state) => self.step(|core, call| core.start(call, state)),
150            StreamingState::Owned(state) => {
151                self.step(move |core, call| core.start_owned(call, state))
152            }
153        }
154    }
155
156    /// Commit the pending occurrence through its checkpoint, then stop.
157    ///
158    /// The returned bytes hold that occurrence's record and nothing that
159    /// follows it. Call [`Self::advance`] for the parent bytes.
160    ///
161    /// [`BoundaryMode::Updatable`] is refused once the response has committed
162    /// as many updatable occurrences as the browser retains. The refusal
163    /// produces no bytes and leaves the occurrence pending, so it can be
164    /// committed with [`BoundaryMode::Final`] instead.
165    /// Pass an owned [`Value`] to move changed subtrees, or a reference to retain
166    /// caller ownership. Equal values preserve the prior state-reference base.
167    /// Use [`Self::resume_current`] when no state changed.
168    pub fn resume<'state>(
169        &mut self,
170        instance_id: BoundaryInstanceId,
171        state: impl Into<StreamingState<'state>>,
172        mode: BoundaryMode,
173    ) -> Result<StreamStep> {
174        match state.into() {
175            StreamingState::Borrowed(state) => {
176                self.step(|core, call| core.resume(call, instance_id, state, mode))
177            }
178            StreamingState::Owned(state) => {
179                self.step(move |core, call| core.resume_owned(call, instance_id, state, mode))
180            }
181        }
182    }
183
184    /// Commit the pending occurrence against the session's retained state.
185    ///
186    /// The call still returns immediately after the checkpoint flush, preserving
187    /// the host's async pause point before [`Self::advance`], but avoids a state
188    /// overlay when no data changed since the preceding step.
189    pub fn resume_current(
190        &mut self,
191        instance_id: BoundaryInstanceId,
192        mode: BoundaryMode,
193    ) -> Result<StreamStep> {
194        self.step(|core, call| core.resume_current(call, instance_id, mode))
195    }
196
197    /// Write the ordinary parent bytes that follow a committed occurrence.
198    ///
199    /// Valid only after [`Self::resume`].
200    pub fn advance(&mut self) -> Result<StreamStep> {
201        self.step(SessionCore::advance)
202    }
203
204    /// Emit an update for one committed updatable runtime occurrence.
205    ///
206    /// Valid between [`Self::resume`] and [`Self::advance`], so a host can
207    /// revise the occurrence it just committed while the response stays open.
208    pub fn update(&mut self, instance_id: BoundaryInstanceId, patch: &Value) -> Result<Vec<u8>> {
209        self.sink.reset();
210        let options = render_options(&self.options);
211        let mut sink = StreamingSink {
212            transport: &mut self.sink,
213            component_opening: None,
214            written: 0,
215            flushed: 0,
216        };
217        let result = self.core.update(
218            SessionCall {
219                handler: &self.handler,
220                protocol: &self.protocol,
221                options: &options,
222                writer: &mut sink,
223            },
224            instance_id,
225            patch,
226        );
227        match result {
228            Ok(()) => {
229                verify_flush_boundary(&self.sink)?;
230                Ok(std::mem::take(&mut self.sink.bytes))
231            }
232            Err(error) => {
233                self.sink.reset();
234                Err(error)
235            }
236        }
237    }
238
239    /// Whether terminal emission completed.
240    #[must_use]
241    pub fn is_done(&self) -> bool {
242        self.core.done
243    }
244
245    fn step(
246        &mut self,
247        operation: impl FnOnce(&mut SessionCore, SessionCall<'_, '_>) -> Result<StreamStatus>,
248    ) -> Result<StreamStep> {
249        self.sink.reset();
250        let options = render_options(&self.options);
251        let mut sink = StreamingSink {
252            transport: &mut self.sink,
253            component_opening: None,
254            written: 0,
255            flushed: 0,
256        };
257        let status = operation(
258            &mut self.core,
259            SessionCall {
260                handler: &self.handler,
261                protocol: &self.protocol,
262                options: &options,
263                writer: &mut sink,
264            },
265        );
266        match status {
267            Ok(status) => {
268                verify_flush_boundary(&self.sink)?;
269                Ok(StreamStep {
270                    bytes: std::mem::take(&mut self.sink.bytes),
271                    boundary: status.boundary,
272                    done: status.done,
273                })
274            }
275            Err(error) => {
276                self.sink.reset();
277                Err(error)
278            }
279        }
280    }
281}
282
283/// Reject a step whose buffered bytes extend past the last transport flush.
284///
285/// A host-owned session hands the caller a byte buffer instead of a live
286/// transport, so a step that produced bytes after its checkpoint flushed would
287/// silently merge two semantic segments into one host write. The check is one
288/// integer compare per step.
289fn verify_flush_boundary(sink: &BufferSink) -> Result<()> {
290    if sink.last_flush == sink.bytes.len() {
291        return Ok(());
292    }
293    Err(unflushed_step_error())
294}
295
296#[cold]
297#[inline(never)]
298fn unflushed_step_error() -> crate::HandlerError {
299    crate::HandlerError::Invariant(
300        "a streaming step buffered bytes past its last flush boundary".to_string(),
301    )
302}
303
304fn render_options(options: &SessionOptions) -> RenderOptions<'_> {
305    RenderOptions {
306        entry_id: &options.entry_id,
307        request_path: &options.request_path,
308        nonce: options.nonce.as_deref(),
309        head_inject: options.head_inject.as_deref(),
310        body_inject: options.body_inject.as_deref(),
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use std::sync::{Arc, Mutex};
317
318    use serde_json::Value;
319
320    use super::{BufferSink, SessionOptions, StreamingSession};
321    use crate::route_handler::Protocol;
322    use crate::{BoundaryMode, FlushWriter, ResponseWriter, WebUIHandler};
323
324    #[test]
325    fn owned_streaming_session_supports_synchronized_hosts() {
326        fn assert_send<T: Send>() {}
327        fn assert_send_sync<T: Send + Sync>() {}
328
329        assert_send::<StreamingSession>();
330        assert_send_sync::<Mutex<StreamingSession>>();
331        assert_send_sync::<Arc<WebUIHandler>>();
332    }
333
334    #[test]
335    fn buffer_sink_tracks_the_transport_flush_boundary() {
336        // The invariant every owned step is checked against: bytes written
337        // after the last flush would merge two semantic segments into one host
338        // write.
339        let mut sink = BufferSink::default();
340        sink.write("checkpoint").expect("write");
341        sink.flush().expect("flush");
342        assert!(super::verify_flush_boundary(&sink).is_ok());
343        sink.write("tail").expect("write");
344        assert!(super::verify_flush_boundary(&sink).is_err());
345        sink.reset();
346        assert!(super::verify_flush_boundary(&sink).is_ok());
347    }
348
349    #[test]
350    fn every_owned_step_ends_on_a_flush_boundary() {
351        let mut parser = webui_parser::HtmlParser::new();
352        parser
353            .parse(
354                "index.html",
355                concat!(
356                    "<html><head></head><body>",
357                    r#"<boundary name="first"><p>1</p></boundary>"#,
358                    "<hr>",
359                    r#"<boundary name="second"><p>2</p></boundary>"#,
360                    "<footer>tail</footer></body></html>",
361                ),
362            )
363            .expect("parse");
364        let protocol = Arc::new(Protocol::new(webui_protocol::WebUIProtocol::new(
365            parser.into_fragment_records(),
366        )));
367        let mut session = StreamingSession::new(
368            Arc::new(WebUIHandler::new()),
369            protocol,
370            SessionOptions::new("index.html", "/"),
371        )
372        .expect("session");
373
374        let state = Arc::new(Value::Object(serde_json::Map::new()));
375        let mut step = session.start(&state).expect("start");
376        let mut steps = 1usize;
377        while !step.done {
378            step = match step.boundary.as_ref() {
379                Some(boundary) => session
380                    .resume(boundary.instance_id, &state, BoundaryMode::Final)
381                    .expect("resume"),
382                None => session.advance().expect("advance"),
383            };
384            steps += 1;
385        }
386        // start, commit, advance, commit, advance.
387        assert_eq!(steps, 5);
388        assert!(session.is_done());
389    }
390}