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