Skip to main content

webui_handler/streaming/
session.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Host-driven lifetime for one streamed HTML response.
5
6use std::borrow::Cow;
7use std::collections::{HashMap, HashSet};
8
9use serde_json::Value;
10
11use super::error::{
12    boundary_not_updatable_error, boundary_order_error, state_update_type_error,
13    unknown_boundary_name_error,
14};
15use super::state::{increment_streaming_record_sequence, StreamingProgress};
16use super::{
17    flush_streaming_transport, validate_streaming_head_start, StreamingEntryPlan,
18    StreamingRenderState, StreamingSink,
19};
20use crate::plugin::HandlerPlugin;
21use crate::route_handler::Protocol;
22use crate::{
23    FlushWriter, HandlerError, RenderOptions, ResponseWriter, Result, WebUIHandler,
24    WebUIProcessContext,
25};
26
27/// Integer handle for a compile-time streaming boundary.
28///
29/// Resolve an authored boundary name once with
30/// [`StreamingResponse::boundary`], then reuse this allocation-free handle for
31/// every response operation.
32#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
33pub struct BoundaryId(u32);
34
35impl BoundaryId {
36    pub(super) fn from_index(index: usize) -> Result<Self> {
37        u32::try_from(index)
38            .map(Self)
39            .map_err(|_| HandlerError::Invariant("boundary index exceeds u32".to_string()))
40    }
41
42    /// Rebuild a handle from the integer a foreign host round-tripped.
43    ///
44    /// Out-of-range values are rejected by the response, not here, so hosts
45    /// that pass an arbitrary integer get the same actionable ordering error a
46    /// Rust caller would.
47    #[must_use]
48    pub fn from_raw(raw: u32) -> Self {
49        Self(raw)
50    }
51
52    /// The integer a foreign host stores and passes back.
53    #[must_use]
54    pub fn raw(self) -> u32 {
55        self.0
56    }
57
58    fn index(self) -> usize {
59        self.0 as usize
60    }
61}
62
63/// Whether a committed boundary may receive later state records.
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65pub enum BoundaryMode {
66    /// Hydrate once and release every boundary-local reference after activation.
67    Final,
68    /// Retain the boundary roots and compiled state projection until terminal.
69    Updatable,
70}
71
72/// A host-controlled progressive HTML response.
73///
74/// Each method synchronously borrows only the state value supplied to that
75/// call. The host may await backend work between calls while this response
76/// retains renderer inventory, transport backpressure, and hydration metadata.
77pub struct StreamingResponse<'a, W: FlushWriter + ?Sized> {
78    handler: &'a WebUIHandler,
79    protocol: &'a Protocol,
80    plan: ResponsePlan<'a>,
81    sink: StreamingSink<'a, W>,
82    request_path: &'a str,
83    entry_id: &'a str,
84    nonce: Option<&'a str>,
85    head_inject: Option<&'a str>,
86    body_inject: Option<&'a str>,
87    /// Index of the next entry fragment to write.
88    cursor: usize,
89    next_boundary: usize,
90    shell_written: bool,
91    finished: bool,
92    failed: bool,
93    local_vars: HashMap<String, Value>,
94    component_attrs: HashMap<String, Value>,
95    route_base: Cow<'a, str>,
96    rendered_components: HashSet<String>,
97    plugin: Option<Box<dyn HandlerPlugin>>,
98    route_children: Vec<webui_protocol::WebUiFragmentRoute>,
99    head_end_emitted: bool,
100    body_start_emitted: bool,
101    body_end_emitted: bool,
102    route_chain_index: usize,
103    entry_route: Option<(String, crate::route_matcher::RouteMatch)>,
104    streaming: StreamingRenderState<'a>,
105    json_scratch: Vec<u8>,
106    scope_pool: Vec<HashMap<String, Value>>,
107}
108
109enum ResponsePlan<'a> {
110    Shared(&'a StreamingEntryPlan),
111    Request(StreamingEntryPlan),
112}
113
114impl ResponsePlan<'_> {
115    fn get(&self) -> &StreamingEntryPlan {
116        match self {
117            Self::Shared(plan) => plan,
118            Self::Request(plan) => plan,
119        }
120    }
121}
122
123/// The half of a [`StreamingResponse`] that carries no borrow.
124///
125/// A host-owned session parks this between calls and rebuilds the borrowed half
126/// from its retained protocol, so a response can outlive any single `&Protocol`
127/// borrow without self-referential storage. Every field here is either owned
128/// outright or reduced to an owned form (`route_base`, the streaming progress)
129/// precisely so that this type has no lifetime parameter.
130pub(super) struct ParkedResponse {
131    cursor: usize,
132    next_boundary: usize,
133    shell_written: bool,
134    finished: bool,
135    failed: bool,
136    local_vars: HashMap<String, Value>,
137    component_attrs: HashMap<String, Value>,
138    route_base: Box<str>,
139    rendered_components: HashSet<String>,
140    plugin: Option<Box<dyn HandlerPlugin>>,
141    route_children: Vec<webui_protocol::WebUiFragmentRoute>,
142    head_end_emitted: bool,
143    body_start_emitted: bool,
144    body_end_emitted: bool,
145    route_chain_index: usize,
146    entry_route: Option<(String, crate::route_matcher::RouteMatch)>,
147    streaming: StreamingProgress,
148    json_scratch: Vec<u8>,
149    scope_pool: Vec<HashMap<String, Value>>,
150    /// Retained only for entries with no shared precomputed plan; shared plans
151    /// are re-resolved from the protocol on every rebuild.
152    request_plan: Option<StreamingEntryPlan>,
153}
154
155impl<'a, W: FlushWriter + ?Sized> StreamingResponse<'a, W> {
156    /// Drop every borrow and keep the owned progress.
157    pub(super) fn park(self) -> ParkedResponse {
158        ParkedResponse {
159            cursor: self.cursor,
160            next_boundary: self.next_boundary,
161            shell_written: self.shell_written,
162            finished: self.finished,
163            failed: self.failed,
164            local_vars: self.local_vars,
165            component_attrs: self.component_attrs,
166            route_base: self.route_base.into_owned().into_boxed_str(),
167            rendered_components: self.rendered_components,
168            plugin: self.plugin,
169            route_children: self.route_children,
170            head_end_emitted: self.head_end_emitted,
171            body_start_emitted: self.body_start_emitted,
172            body_end_emitted: self.body_end_emitted,
173            route_chain_index: self.route_chain_index,
174            entry_route: self.entry_route,
175            streaming: self.streaming.into_progress(),
176            json_scratch: self.json_scratch,
177            scope_pool: self.scope_pool,
178            request_plan: match self.plan {
179                ResponsePlan::Shared(_) => None,
180                ResponsePlan::Request(plan) => Some(plan),
181            },
182        }
183    }
184
185    /// Rebuild a borrowed response around parked progress.
186    pub(super) fn unpark(
187        parked: ParkedResponse,
188        handler: &'a WebUIHandler,
189        protocol: &'a Protocol,
190        options: &RenderOptions<'a>,
191        writer: &'a mut W,
192    ) -> Result<Self> {
193        let plan = match parked.request_plan {
194            Some(plan) => ResponsePlan::Request(plan),
195            None => match protocol.streaming_plan(options.entry_id)? {
196                Some(plan) => ResponsePlan::Shared(plan),
197                None => {
198                    return Err(HandlerError::Invariant(
199                        "streaming response plan disappeared between calls".to_string(),
200                    ))
201                }
202            },
203        };
204        Ok(Self {
205            handler,
206            protocol,
207            plan,
208            sink: StreamingSink { transport: writer },
209            request_path: options.request_path,
210            entry_id: options.entry_id,
211            nonce: options.nonce.filter(|nonce| !nonce.is_empty()),
212            head_inject: options.head_inject.filter(|html| !html.is_empty()),
213            body_inject: options.body_inject.filter(|html| !html.is_empty()),
214            cursor: parked.cursor,
215            next_boundary: parked.next_boundary,
216            shell_written: parked.shell_written,
217            finished: parked.finished,
218            failed: parked.failed,
219            local_vars: parked.local_vars,
220            component_attrs: parked.component_attrs,
221            route_base: Cow::Owned(parked.route_base.into_string()),
222            rendered_components: parked.rendered_components,
223            plugin: parked.plugin,
224            route_children: parked.route_children,
225            head_end_emitted: parked.head_end_emitted,
226            body_start_emitted: parked.body_start_emitted,
227            body_end_emitted: parked.body_end_emitted,
228            route_chain_index: parked.route_chain_index,
229            entry_route: parked.entry_route,
230            streaming: StreamingRenderState::from_progress(
231                parked.streaming,
232                protocol.component_reachability(),
233            ),
234            json_scratch: parked.json_scratch,
235            scope_pool: parked.scope_pool,
236        })
237    }
238}
239
240impl WebUIHandler {
241    /// Start a host-driven progressive HTML response.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error when the entry is missing or its compiled streaming
246    /// structure is malformed.
247    pub fn stream_response<'a, W: FlushWriter + ?Sized>(
248        &'a self,
249        protocol: &'a Protocol,
250        options: &RenderOptions<'a>,
251        writer: &'a mut W,
252    ) -> Result<StreamingResponse<'a, W>> {
253        let document = protocol.protocol();
254        let fragments = document
255            .fragments
256            .get(options.entry_id)
257            .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
258        validate_streaming_head_start(document, options.entry_id)?;
259        let plan = match protocol.streaming_plan(options.entry_id)? {
260            Some(plan) => ResponsePlan::Shared(plan),
261            None => ResponsePlan::Request(StreamingEntryPlan::new(
262                options.entry_id,
263                &fragments.fragments,
264                None,
265            )?),
266        };
267        let component_count = protocol.component_index().len();
268        let entry_route = crate::route_renderer::find_best_route_match(
269            &fragments.fragments,
270            options.request_path,
271            "/",
272            protocol.route_index(),
273        );
274
275        Ok(StreamingResponse {
276            handler: self,
277            protocol,
278            plan,
279            sink: StreamingSink { transport: writer },
280            request_path: options.request_path,
281            entry_id: options.entry_id,
282            nonce: options.nonce.filter(|nonce| !nonce.is_empty()),
283            head_inject: options.head_inject.filter(|html| !html.is_empty()),
284            body_inject: options.body_inject.filter(|html| !html.is_empty()),
285            cursor: 0,
286            next_boundary: 0,
287            shell_written: false,
288            finished: false,
289            failed: false,
290            local_vars: HashMap::new(),
291            component_attrs: HashMap::new(),
292            route_base: Cow::Borrowed("/"),
293            rendered_components: HashSet::new(),
294            plugin: self.plugin_factory.map(|factory| factory()),
295            route_children: Vec::new(),
296            head_end_emitted: false,
297            body_start_emitted: false,
298            body_end_emitted: false,
299            route_chain_index: 0,
300            entry_route,
301            streaming: StreamingRenderState::from_progress(
302                StreamingProgress::new(component_count),
303                protocol.component_reachability(),
304            ),
305            json_scratch: Vec::new(),
306            scope_pool: Vec::new(),
307        })
308    }
309}
310
311impl<'a, W: FlushWriter + ?Sized> StreamingResponse<'a, W> {
312    /// Resolve a free-form authored name to an integer response handle.
313    ///
314    /// # Errors
315    ///
316    /// Returns an actionable error with valid names and a typo suggestion when
317    /// the entry does not declare `name`.
318    pub fn boundary(&self, name: &str) -> Result<BoundaryId> {
319        let names = self.protocol.streaming_boundary_names(self.entry_id);
320        let Some(index) = names.iter().position(|candidate| candidate == name) else {
321            return Err(unknown_boundary_name_error(name, names));
322        };
323        BoundaryId::from_index(index)
324    }
325
326    /// Number of compile-time boundaries in this entry.
327    #[must_use]
328    pub fn boundary_count(&self) -> usize {
329        self.plan.get().boundary_count()
330    }
331
332    /// Render and flush the document prefix before the first boundary.
333    pub fn write_shell(&mut self, state: &Value) -> Result<()> {
334        self.write_shell_internal(state, true)
335    }
336
337    pub(super) fn write_shell_buffered(&mut self, state: &Value) -> Result<()> {
338        self.write_shell_internal(state, false)
339    }
340
341    fn write_shell_internal(&mut self, state: &Value, flush: bool) -> Result<()> {
342        self.require_open("write_shell")?;
343        if self.shell_written {
344            return Err(boundary_order_error(
345                "write_shell",
346                "the response shell has already been written",
347            ));
348        }
349        let shell_end = self.plan.get().shell_end();
350        let result = self.run_range(state, 0..shell_end);
351        self.poison_on_error(&result);
352        result?;
353        self.cursor = shell_end;
354        self.shell_written = true;
355        if flush && !self.body_ended() {
356            let result = self.with_context(state, |_handler, context| {
357                flush_streaming_transport(context)
358            });
359            self.poison_on_error(&result);
360            result?;
361        }
362        Ok(())
363    }
364
365    /// Render, commit, and flush the next compile-time boundary.
366    pub fn write_boundary(
367        &mut self,
368        boundary: BoundaryId,
369        state: &Value,
370        mode: BoundaryMode,
371    ) -> Result<()> {
372        self.require_open("write_boundary")?;
373        if !self.shell_written {
374            return Err(boundary_order_error(
375                "write_boundary",
376                "write_shell must be called before the first boundary",
377            ));
378        }
379        let index = boundary.index();
380        if index != self.next_boundary {
381            return Err(boundary_order_error(
382                "write_boundary",
383                "boundaries must be written once in declaration order",
384            ));
385        }
386        let range = self
387            .plan
388            .get()
389            .boundary(index)
390            .ok_or_else(|| boundary_order_error("write_boundary", "boundary ID is out of range"))?
391            .clone();
392        if range.start < self.cursor {
393            return Err(boundary_order_error(
394                "write_boundary",
395                "boundary content has already been written",
396            ));
397        }
398        self.streaming.checkpoint_updatable = mode == BoundaryMode::Updatable;
399        let result = self.run_range(state, self.cursor..range.end);
400        self.poison_on_error(&result);
401        result?;
402        self.cursor = range.end;
403        self.next_boundary += 1;
404        Ok(())
405    }
406
407    /// Push a projected state patch to an already committed updatable boundary.
408    pub fn update(&mut self, boundary: BoundaryId, state: &Value) -> Result<()> {
409        self.require_open("update")?;
410        let target = boundary.index();
411        if target >= self.next_boundary {
412            return Err(boundary_order_error(
413                "update",
414                "the target boundary has not committed yet",
415            ));
416        }
417        if !state.is_object() {
418            return Err(state_update_type_error());
419        }
420        if self
421            .streaming
422            .update_plans
423            .get(target)
424            .and_then(Option::as_ref)
425            .is_none()
426        {
427            return Err(boundary_not_updatable_error(target));
428        }
429        let result = self.with_context(state, |handler, context| {
430            let record_sequence = context
431                .streaming
432                .as_ref()
433                .map_or(0, |streaming| streaming.next_record_sequence);
434            handler.emit_streaming_state_update(record_sequence, target, context)?;
435            increment_streaming_record_sequence("state_update", super::streaming_state(context)?)
436        });
437        self.poison_on_error(&result);
438        result
439    }
440
441    /// Render the document tail, emit the terminal record, and end the writer.
442    pub fn finish(mut self, state: &Value) -> Result<()> {
443        self.ensure_finishable()?;
444        if !self.body_ended() {
445            let fragment_count = self
446                .protocol
447                .protocol()
448                .fragments
449                .get(self.entry_id)
450                .map_or(0, |fragments| fragments.fragments.len());
451            self.run_range(state, self.cursor..fragment_count)?;
452        }
453        if !self.body_ended() {
454            return Err(HandlerError::MissingStreamingBodyEnd);
455        }
456        self.finished = true;
457        self.sink.end()
458    }
459
460    fn require_open(&self, operation: &str) -> Result<()> {
461        self.require_usable(operation)?;
462        if self.finished || self.body_ended() {
463            Err(boundary_order_error(
464                operation,
465                "the streaming response has already finished",
466            ))
467        } else {
468            Ok(())
469        }
470    }
471
472    /// Validate that [`Self::finish`] may run, without consuming the response.
473    ///
474    /// Every check here runs before any byte is written, so a caller that
475    /// violates ordering can correct the sequence and finish later. Owned
476    /// sessions rely on that to re-park a response after a rejected `finish`.
477    pub(super) fn ensure_finishable(&self) -> Result<()> {
478        self.require_usable("finish")?;
479        if self.finished {
480            return Err(boundary_order_error(
481                "finish",
482                "the streaming response has already finished",
483            ));
484        }
485        if !self.shell_written {
486            return Err(boundary_order_error(
487                "finish",
488                "write_shell must be called before finish",
489            ));
490        }
491        if self.next_boundary != self.plan.get().boundary_count() {
492            return Err(boundary_order_error(
493                "finish",
494                "every boundary must be committed before finish",
495            ));
496        }
497        Ok(())
498    }
499
500    fn require_usable(&self, operation: &str) -> Result<()> {
501        if self.failed {
502            return Err(boundary_order_error(
503                operation,
504                "the streaming response is unusable after a previous render or transport failure; \
505                 start a new response because bytes may already have been sent",
506            ));
507        }
508        Ok(())
509    }
510
511    fn poison_on_error<T>(&mut self, result: &Result<T>) {
512        if result.is_err() {
513            self.failed = true;
514        }
515    }
516
517    fn body_ended(&self) -> bool {
518        self.streaming.body_ended
519    }
520
521    fn run_range(&mut self, state: &Value, range: std::ops::Range<usize>) -> Result<()> {
522        let protocol: &'a Protocol = self.protocol;
523        let fragments = protocol
524            .protocol()
525            .fragments
526            .get(self.entry_id)
527            .ok_or_else(|| HandlerError::MissingFragment(self.entry_id.to_string()))?;
528        let entry_route = self.entry_route.take();
529        let result = self.with_context(state, |handler, context| {
530            handler.process_fragment_range(&fragments.fragments, range, &entry_route, context)
531        });
532        self.entry_route = entry_route;
533        result
534    }
535
536    fn with_context<'state, T>(
537        &mut self,
538        state: &'state Value,
539        operation: impl for<'output> FnOnce(
540            &WebUIHandler,
541            &mut WebUIProcessContext<'a, 'state, 'output>,
542        ) -> Result<T>,
543    ) -> Result<T> {
544        let mut context = WebUIProcessContext {
545            protocol: self.protocol.protocol(),
546            state,
547            writer: &mut self.sink,
548            local_vars: std::mem::take(&mut self.local_vars),
549            component_attrs: std::mem::take(&mut self.component_attrs),
550            request_path: self.request_path,
551            route_base: std::mem::replace(&mut self.route_base, Cow::Borrowed("/")),
552            rendered_components: std::mem::take(&mut self.rendered_components),
553            plugin: self.plugin.take(),
554            route_children: std::mem::take(&mut self.route_children),
555            entry_id: self.entry_id,
556            nonce: self.nonce,
557            component_index: self.protocol.component_index(),
558            head_inject: self.head_inject,
559            body_inject: self.body_inject,
560            state_inject: crate::StateInject::resolve(state),
561            head_end_emitted: self.head_end_emitted,
562            body_start_emitted: self.body_start_emitted,
563            body_end_emitted: self.body_end_emitted,
564            route_index: self.protocol.route_index(),
565            route_chain_index: self.route_chain_index,
566            streaming: Some(&mut self.streaming),
567            json_scratch: std::mem::take(&mut self.json_scratch),
568            scope_pool: std::mem::take(&mut self.scope_pool),
569        };
570
571        let result = operation(self.handler, &mut context);
572        self.local_vars = std::mem::take(&mut context.local_vars);
573        self.component_attrs = std::mem::take(&mut context.component_attrs);
574        self.route_base = std::mem::replace(&mut context.route_base, Cow::Borrowed("/"));
575        self.rendered_components = std::mem::take(&mut context.rendered_components);
576        self.plugin = context.plugin.take();
577        self.route_children = std::mem::take(&mut context.route_children);
578        self.head_end_emitted = context.head_end_emitted;
579        self.body_start_emitted = context.body_start_emitted;
580        self.body_end_emitted = context.body_end_emitted;
581        self.route_chain_index = context.route_chain_index;
582        self.json_scratch = std::mem::take(&mut context.json_scratch);
583        self.scope_pool = std::mem::take(&mut context.scope_pool);
584        result
585    }
586}