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