Skip to main content

webui_handler/plugin/
mod.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Handler plugin trait and built-in plugin implementations.
5//!
6//! Handler plugins write framework-specific hydration markers while shared
7//! completion work, such as component template emission, stays in handler core.
8
9pub mod fast;
10pub mod fast_v2;
11pub mod fast_v3;
12pub mod webui;
13
14use crate::{ResponseWriter, Result};
15use webui_protocol::WebUIProtocol;
16
17/// Split WebUI component template payload used by SSR bootstrap emission.
18pub struct WebUiTemplatePayload<'a> {
19    /// Component custom-element tag name.
20    pub tag_name: &'a str,
21    /// JSON-safe template metadata object.
22    pub template_json: &'a str,
23    /// Component-local JavaScript condition closure array.
24    pub template_functions: &'a str,
25}
26
27/// Context passed to plugin-specific SSR bootstrap extension hooks.
28pub struct BootstrapExtensionContext<'a> {
29    /// Full protocol for plugins that need additional component metadata.
30    pub protocol: &'a WebUIProtocol,
31    /// Route-reachable component tags for this render, in deterministic
32    /// traversal order (not a `HashSet`, whose iteration order varies with
33    /// the process's randomized hash seed).
34    pub components: &'a [String],
35    /// Split WebUI template payloads collected for this render.
36    pub payloads: &'a [WebUiTemplatePayload<'a>],
37    /// CSP nonce for executable scripts, when configured.
38    pub nonce: Option<&'a str>,
39}
40
41/// A handler plugin that can inject additional content during rendering.
42///
43/// Plugins receive callbacks at key points in the rendering lifecycle:
44/// - **Scope management**: `push_scope` / `pop_scope` for component and loop boundaries
45/// - **Binding lifecycle**: binding hooks around escaped and raw signals
46/// - **For-loop lifecycle**: `on_for_start` / `on_for_end` around for-loop blocks
47/// - **If-condition lifecycle**: `on_if_start` / `on_if_end` around if-condition blocks
48/// - **Repeat items**: `on_repeat_item_start` / `on_repeat_item_end` per for-loop item
49/// - **Element data**: `on_element_data` for parser-produced hydration metadata
50/// - **Route state**: `write_route_component_state` for framework-specific opening-tag attributes
51///
52/// WebUI does not interpret what plugins write — it just calls the hooks.
53/// Each framework defines its own marker format.
54///
55/// # Threading
56///
57/// Plugins must be [`Send`] because the same handler can create an owned
58/// [`StreamingSession`](crate::StreamingSession). That session parks its live
59/// per-render plugin between calls and may move to another host thread. Rust
60/// cannot safely add `Send` after a factory result has been erased to
61/// `Box<dyn HandlerPlugin>`, so the guarantee is established here.
62///
63/// Plugins do not need to be [`Sync`]. Every render receives a fresh instance,
64/// and WebUI never invokes one instance concurrently. Sendable interior
65/// mutability such as [`Cell`](std::cell::Cell) and
66/// [`RefCell`](std::cell::RefCell) remains supported; thread-affine state such
67/// as [`Rc`](std::rc::Rc) must be replaced with owned data, [`Arc`](std::sync::Arc),
68/// or another sendable handle.
69pub trait HandlerPlugin: Send {
70    /// Enter a new scope (component or for-loop item boundary).
71    /// Typically resets per-scope counters.
72    fn push_scope(&mut self);
73
74    /// Exit the current scope, restoring the parent scope state.
75    fn pop_scope(&mut self);
76
77    /// Called before rendering a signal binding.
78    ///
79    /// `raw` is true when the signal owns a replaceable HTML sibling range.
80    fn on_binding_start(
81        &mut self,
82        name: &str,
83        raw: bool,
84        writer: &mut dyn ResponseWriter,
85    ) -> Result<()>;
86
87    /// Called after rendering a signal binding.
88    ///
89    /// `raw` is true when the signal owns a replaceable HTML sibling range.
90    fn on_binding_end(
91        &mut self,
92        name: &str,
93        raw: bool,
94        writer: &mut dyn ResponseWriter,
95    ) -> Result<()>;
96
97    /// Called before rendering a for-loop block.
98    /// Defaults to [`on_binding_start`](HandlerPlugin::on_binding_start).
99    fn on_for_start(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
100        self.on_binding_start(name, false, writer)
101    }
102
103    /// Called after rendering a for-loop block.
104    /// Defaults to [`on_binding_end`](HandlerPlugin::on_binding_end).
105    fn on_for_end(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
106        self.on_binding_end(name, false, writer)
107    }
108
109    /// Called before rendering an if-condition block.
110    /// Defaults to [`on_binding_start`](HandlerPlugin::on_binding_start).
111    fn on_if_start(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
112        self.on_binding_start(name, false, writer)
113    }
114
115    /// Called after rendering an if-condition block.
116    /// Defaults to [`on_binding_end`](HandlerPlugin::on_binding_end).
117    fn on_if_end(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
118        self.on_binding_end(name, false, writer)
119    }
120
121    /// Called before rendering a repeat item in a for loop.
122    fn on_repeat_item_start(&mut self, index: usize, writer: &mut dyn ResponseWriter)
123        -> Result<()>;
124
125    /// Called after rendering a repeat item.
126    fn on_repeat_item_end(&mut self, index: usize, writer: &mut dyn ResponseWriter) -> Result<()>;
127
128    /// Called when parser-produced element metadata is encountered.
129    fn on_element_data(&mut self, data: &[u8], writer: &mut dyn ResponseWriter) -> Result<()>;
130
131    /// Called when emitting a matched route component's opening tag.
132    /// Plugins can write framework-specific attributes before the closing `>`.
133    /// The default is a no-op.
134    fn write_route_component_state(
135        &self,
136        _state: &serde_json::Value,
137        _writer: &mut dyn ResponseWriter,
138    ) -> Result<()> {
139        Ok(())
140    }
141
142    /// Emit component templates collected during SSR.  The default emits
143    /// each template as-is (suitable for FAST `<f-template>` tags).  The
144    /// WebUI split-payload path uses [`HandlerPlugin::collect_template_payloads`]
145    /// instead.
146    fn emit_templates(
147        &self,
148        protocol: &WebUIProtocol,
149        components: &[String],
150        _nonce: Option<&str>,
151        writer: &mut dyn ResponseWriter,
152    ) -> Result<()> {
153        emit_component_templates(protocol, components, writer)
154    }
155
156    /// Return split WebUI template payloads for the given components.
157    ///
158    /// The WebUI plugin overrides this so `lib.rs` can emit JSON metadata in an
159    /// inert data block and only emit condition closures as executable JS.
160    /// Returns `None` when templates are non-WebUI payloads (e.g. FAST
161    /// `<f-template>` tags).
162    fn collect_template_payloads<'a>(
163        &self,
164        _protocol: &'a WebUIProtocol,
165        _components: &[String],
166    ) -> Option<Vec<WebUiTemplatePayload<'a>>> {
167        None
168    }
169
170    /// Slice-based counterpart to [`HandlerPlugin::emit_templates`].
171    ///
172    /// The streaming checkpoint path captures the exact component tags rendered
173    /// since the previous checkpoint as a borrowed `&[&str]`, avoiding the owned
174    /// `Vec<String>` the ordinary body-end path builds. The default forwards
175    /// to [`emit_component_templates_slice`] (verbatim FAST `<f-template>`
176    /// emission).
177    fn emit_templates_slice(
178        &self,
179        protocol: &WebUIProtocol,
180        tags: &[&str],
181        _nonce: Option<&str>,
182        writer: &mut dyn ResponseWriter,
183    ) -> Result<()> {
184        emit_component_templates_slice(protocol, tags, writer)
185    }
186
187    /// Slice-based counterpart to [`HandlerPlugin::collect_template_payloads`].
188    ///
189    /// Consumes a borrowed `&[&str]` of component tags so the streaming path can
190    /// project per-checkpoint templates without materializing an owned set. The
191    /// default returns `None`.
192    fn collect_template_payloads_slice<'a>(
193        &self,
194        _protocol: &'a WebUIProtocol,
195        _tags: &[&str],
196    ) -> Option<Vec<WebUiTemplatePayload<'a>>> {
197        None
198    }
199
200    /// Emit plugin-specific executable SSR bootstrap code for the streaming
201    /// path, given only the already-collected template payloads.
202    ///
203    /// Unlike [`HandlerPlugin::emit_bootstrap_extension`], this takes no
204    /// `Vec<String>` component set — the streaming checkpoint has already
205    /// projected the exact per-checkpoint payloads. The default is a no-op.
206    fn emit_bootstrap_extension_payloads(
207        &self,
208        _payloads: &[WebUiTemplatePayload<'_>],
209        _nonce: Option<&str>,
210        _writer: &mut dyn ResponseWriter,
211    ) -> Result<()> {
212        Ok(())
213    }
214
215    /// Emit plugin-specific executable SSR bootstrap code, if needed.
216    ///
217    /// The handler emits shared metadata as inert `#webui-data`; client
218    /// packages parse that data lazily. Plugins can still emit executable
219    /// side-channel data here, such as WebUI framework `templateFns` closures.
220    /// The default is a no-op for FAST plugins, which use `<f-template>` tags.
221    fn emit_bootstrap_extension(
222        &self,
223        _context: BootstrapExtensionContext<'_>,
224        _writer: &mut dyn ResponseWriter,
225    ) -> Result<()> {
226        Ok(())
227    }
228}
229
230/// Default template emission: write each non-empty template verbatim.
231/// Used by FAST parser plugins for `<f-template>` tags.
232pub(crate) fn emit_component_templates(
233    protocol: &WebUIProtocol,
234    components: &[String],
235    writer: &mut dyn ResponseWriter,
236) -> Result<()> {
237    for name in components {
238        if let Some(template) = protocol
239            .components
240            .get(name)
241            .map(|component| component.template.as_str())
242            .filter(|template| !template.is_empty())
243        {
244            writer.write(template)?;
245        }
246    }
247    Ok(())
248}
249
250/// Slice-based counterpart to [`emit_component_templates`].
251///
252/// Writes each non-empty template verbatim for the borrowed component tags,
253/// used by the streaming checkpoint fallback path.
254pub(crate) fn emit_component_templates_slice(
255    protocol: &WebUIProtocol,
256    tags: &[&str],
257    writer: &mut dyn ResponseWriter,
258) -> Result<()> {
259    for &name in tags {
260        if let Some(template) = protocol
261            .components
262            .get(name)
263            .map(|component| component.template.as_str())
264            .filter(|template| !template.is_empty())
265        {
266            writer.write(template)?;
267        }
268    }
269    Ok(())
270}
271
272#[cfg(test)]
273mod tests {
274    use std::cell::Cell;
275
276    use super::HandlerPlugin;
277    use crate::{ResponseWriter, Result};
278
279    #[derive(Default)]
280    struct SendOnlyPlugin {
281        scope_depth: Cell<usize>,
282        binding_calls: Cell<usize>,
283        raw_binding_calls: Cell<usize>,
284    }
285
286    struct TestWriter;
287
288    impl ResponseWriter for TestWriter {
289        fn write(&mut self, _content: &str) -> Result<()> {
290            Ok(())
291        }
292
293        fn end(&mut self) -> Result<()> {
294            Ok(())
295        }
296    }
297
298    impl HandlerPlugin for SendOnlyPlugin {
299        fn push_scope(&mut self) {
300            self.scope_depth
301                .set(self.scope_depth.get().saturating_add(1));
302        }
303
304        fn pop_scope(&mut self) {
305            self.scope_depth
306                .set(self.scope_depth.get().saturating_sub(1));
307        }
308
309        fn on_binding_start(
310            &mut self,
311            _name: &str,
312            raw: bool,
313            _writer: &mut dyn ResponseWriter,
314        ) -> Result<()> {
315            self.binding_calls
316                .set(self.binding_calls.get().saturating_add(1));
317            if raw {
318                self.raw_binding_calls
319                    .set(self.raw_binding_calls.get().saturating_add(1));
320            }
321            Ok(())
322        }
323
324        fn on_binding_end(
325            &mut self,
326            _name: &str,
327            raw: bool,
328            _writer: &mut dyn ResponseWriter,
329        ) -> Result<()> {
330            self.binding_calls
331                .set(self.binding_calls.get().saturating_add(1));
332            if raw {
333                self.raw_binding_calls
334                    .set(self.raw_binding_calls.get().saturating_add(1));
335            }
336            Ok(())
337        }
338
339        fn on_repeat_item_start(
340            &mut self,
341            _index: usize,
342            _writer: &mut dyn ResponseWriter,
343        ) -> Result<()> {
344            Ok(())
345        }
346
347        fn on_repeat_item_end(
348            &mut self,
349            _index: usize,
350            _writer: &mut dyn ResponseWriter,
351        ) -> Result<()> {
352            Ok(())
353        }
354
355        fn on_element_data(
356            &mut self,
357            _data: &[u8],
358            _writer: &mut dyn ResponseWriter,
359        ) -> Result<()> {
360            Ok(())
361        }
362    }
363
364    #[test]
365    fn handler_plugin_is_send_but_does_not_require_sync() {
366        fn assert_send<T: Send + ?Sized>() {}
367        fn assert_plugin<T: HandlerPlugin>() {}
368
369        assert_send::<dyn HandlerPlugin>();
370        assert_plugin::<SendOnlyPlugin>();
371    }
372
373    #[test]
374    fn binding_hooks_receive_raw_range_flag() {
375        let mut plugin = SendOnlyPlugin::default();
376        let mut writer = TestWriter;
377
378        plugin
379            .on_binding_start("trusted_html", true, &mut writer)
380            .unwrap();
381        plugin
382            .on_binding_end("trusted_html", true, &mut writer)
383            .unwrap();
384
385        assert_eq!(plugin.binding_calls.get(), 2);
386        assert_eq!(plugin.raw_binding_calls.get(), 2);
387    }
388}