Skip to main content

webui_handler/plugin/
fast_v2.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Deprecated FAST 2 hydration plugin for the WebUI handler.
5//!
6//! Emits the legacy FAST 2 marker format used by the `fast` and `fast-v2`
7//! plugin names. FAST 3 hydration is implemented separately in `fast_v3`.
8//!
9//! ## FAST 2 Comment Format
10//!
11//! - **Binding start**: `<!--fe-b$$start$$INDEX$$NAME$$fe-b-->`
12//! - **Binding end**: `<!--fe-b$$end$$INDEX$$NAME$$fe-b-->`
13//! - **Repeat item start**: `<!--fe-repeat$$start$$INDEX$$fe-repeat-->`
14//! - **Repeat item end**: `<!--fe-repeat$$end$$INDEX$$fe-repeat-->`
15//! - **Single attribute binding**: ` data-fe-b-INDEX`
16//! - **Multiple attribute bindings**: ` data-fe-c-INDEX-COUNT`
17use super::HandlerPlugin;
18use crate::{HandlerError, ResponseWriter, Result};
19use serde_json::Value;
20use std::fmt::Write;
21use webui_protocol::FastElementData;
22
23// FAST 2 comment format constants
24const V2_BINDING_START_PREFIX: &str = "<!--fe-b$$start$$";
25const V2_BINDING_END_PREFIX: &str = "<!--fe-b$$end$$";
26const V2_BINDING_SUFFIX: &str = "$$fe-b-->";
27const V2_SEPARATOR: &str = "$$";
28const V2_REPEAT_START_PREFIX: &str = "<!--fe-repeat$$start$$";
29const V2_REPEAT_END_PREFIX: &str = "<!--fe-repeat$$end$$";
30const V2_REPEAT_SUFFIX: &str = "$$fe-repeat-->";
31const V2_ATTR_SINGLE_PREFIX: &str = " data-fe-b-";
32const V2_ATTR_MULTI_PREFIX: &str = " data-fe-c-";
33
34/// Deprecated FAST 2 hydration handler plugin.
35///
36/// Emits the legacy FAST 2 marker format used by the `fast` and `fast-v2`
37/// plugin names. New FAST 3 applications should use the separate
38/// `fast_v3::FastV3HydrationPlugin` implementation through `fast-v3` instead.
39pub struct FastV2HydrationPlugin {
40    /// Stack of local binding counters (one per scope).
41    /// The bottom of the stack is the root scope (disabled).
42    scopes: Vec<usize>,
43    /// Stack of binding indices for matching start/end pairs.
44    binding_stack: Vec<usize>,
45    /// Reusable buffer for formatting markers without allocation.
46    buffer: String,
47}
48
49impl FastV2HydrationPlugin {
50    /// Create a new deprecated FAST 2 hydration plugin.
51    /// The initial root scope is disabled — markers only emit in child scopes.
52    #[must_use]
53    pub fn new() -> Self {
54        Self {
55            // Root scope (index 0) is disabled — only scopes.len() > 1 are active.
56            scopes: vec![0],
57            binding_stack: Vec::with_capacity(8),
58            buffer: String::with_capacity(64),
59        }
60    }
61
62    /// Whether the current scope is active (not the root scope).
63    fn is_active(&self) -> bool {
64        self.scopes.len() > 1
65    }
66
67    /// Get the next binding index in the current scope, advancing the counter.
68    fn next_index(&mut self) -> usize {
69        if let Some(counter) = self.scopes.last_mut() {
70            let index = *counter;
71            *counter += 1;
72            index
73        } else {
74            0
75        }
76    }
77
78    /// Get the next binding index, advancing the counter by `count`.
79    fn next_index_n(&mut self, count: u32) -> usize {
80        if let Some(counter) = self.scopes.last_mut() {
81            let index = *counter;
82            *counter += count as usize;
83            index
84        } else {
85            0
86        }
87    }
88
89    /// Build a binding comment into the reusable buffer.
90    fn build_binding_comment(&mut self, prefix: &str, index: usize, name: &str) {
91        self.buffer.clear();
92        self.buffer.push_str(prefix);
93        let _ = write!(self.buffer, "{}", index);
94        self.buffer.push_str(V2_SEPARATOR);
95        self.buffer.push_str(name);
96        self.buffer.push_str(V2_BINDING_SUFFIX);
97    }
98
99    /// Build a repeat comment into the reusable buffer.
100    fn build_repeat_comment(&mut self, prefix: &str, index: usize) {
101        self.buffer.clear();
102        self.buffer.push_str(prefix);
103        let _ = write!(self.buffer, "{}", index);
104        self.buffer.push_str(V2_REPEAT_SUFFIX);
105    }
106
107    /// Build an attribute binding marker into the reusable buffer.
108    fn build_attribute_marker(&mut self, binding_index: usize, count: u32) {
109        self.buffer.clear();
110        if count == 1 {
111            self.buffer.push_str(V2_ATTR_SINGLE_PREFIX);
112            let _ = write!(self.buffer, "{}", binding_index);
113        } else {
114            self.buffer.push_str(V2_ATTR_MULTI_PREFIX);
115            let _ = write!(self.buffer, "{}-{}", binding_index, count);
116        }
117    }
118}
119
120impl Default for FastV2HydrationPlugin {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126impl HandlerPlugin for FastV2HydrationPlugin {
127    fn push_scope(&mut self) {
128        self.scopes.push(0);
129    }
130
131    fn pop_scope(&mut self) {
132        self.scopes.pop();
133    }
134
135    fn on_binding_start(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
136        if !self.is_active() {
137            return Ok(());
138        }
139        let index = self.next_index();
140        self.binding_stack.push(index);
141        self.build_binding_comment(V2_BINDING_START_PREFIX, index, name);
142        writer.write(&self.buffer)
143    }
144
145    fn on_binding_end(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
146        if !self.is_active() {
147            return Ok(());
148        }
149        let index = self.binding_stack.pop().unwrap_or(0);
150        self.build_binding_comment(V2_BINDING_END_PREFIX, index, name);
151        writer.write(&self.buffer)
152    }
153
154    fn on_repeat_item_start(
155        &mut self,
156        index: usize,
157        writer: &mut dyn ResponseWriter,
158    ) -> Result<()> {
159        if !self.is_active() {
160            return Ok(());
161        }
162        self.build_repeat_comment(V2_REPEAT_START_PREFIX, index);
163        writer.write(&self.buffer)
164    }
165
166    fn on_repeat_item_end(&mut self, index: usize, writer: &mut dyn ResponseWriter) -> Result<()> {
167        if !self.is_active() {
168            return Ok(());
169        }
170        self.build_repeat_comment(V2_REPEAT_END_PREFIX, index);
171        writer.write(&self.buffer)
172    }
173
174    fn on_element_data(&mut self, data: &[u8], writer: &mut dyn ResponseWriter) -> Result<()> {
175        if !self.is_active() {
176            return Ok(());
177        }
178        let decoded = FastElementData::decode(data).map_err(|error| {
179            HandlerError::PluginData(format!(
180                "FAST v2 hydration plugin expected 4 bytes of element data: {error}"
181            ))
182        })?;
183        if decoded.binding_count > 0 {
184            let binding_index = self.next_index_n(decoded.binding_count);
185            self.build_attribute_marker(binding_index, decoded.binding_count);
186            writer.write(&self.buffer)?;
187        }
188        Ok(())
189    }
190
191    /// FAST emits scalar attributes + `data-state` JSON on route component elements.
192    /// Components read these via `@attr` and their connection lifecycle.
193    fn write_route_component_state(
194        &self,
195        state: &Value,
196        writer: &mut dyn ResponseWriter,
197    ) -> Result<()> {
198        write_fast_route_component_state(state, writer)
199    }
200}
201
202fn write_fast_route_component_state(state: &Value, writer: &mut dyn ResponseWriter) -> Result<()> {
203    let map = match state.as_object() {
204        Some(m) => m,
205        None => return Ok(()),
206    };
207
208    // Emit scalar values as individual kebab-case attributes.
209    for (key, value) in map {
210        let val_str = match value {
211            Value::String(s) => std::borrow::Cow::Borrowed(s.as_str()),
212            Value::Number(n) => std::borrow::Cow::Owned(n.to_string()),
213            Value::Bool(true) => std::borrow::Cow::Borrowed("true"),
214            Value::Bool(false) => std::borrow::Cow::Borrowed("false"),
215            _ => continue,
216        };
217        let attr_name = webui_protocol::attrs::camel_to_kebab(key);
218        writer.write(" ")?;
219        writer.write(&attr_name)?;
220        writer.write("=\"")?;
221        crate::route_renderer::write_escaped_state_attr(writer, val_str.as_ref())?;
222        writer.write("\"")?;
223    }
224
225    // Emit data-state JSON for complex values (arrays, objects).
226    let has_complex = map.values().any(|v| v.is_array() || v.is_object());
227    if has_complex {
228        let json_str = state.to_string();
229        writer.write(" data-state=\"")?;
230        crate::route_renderer::write_escaped_state_attr(writer, &json_str)?;
231        writer.write("\"")?;
232    }
233
234    Ok(())
235}
236
237#[cfg(test)]
238mod tests {
239    #![allow(clippy::disallowed_methods)]
240
241    use super::*;
242
243    struct TestWriter {
244        output: String,
245    }
246
247    impl TestWriter {
248        fn new() -> Self {
249            Self {
250                output: String::new(),
251            }
252        }
253    }
254
255    impl ResponseWriter for TestWriter {
256        fn write(&mut self, content: &str) -> Result<()> {
257            self.output.push_str(content);
258            Ok(())
259        }
260        fn end(&mut self) -> Result<()> {
261            Ok(())
262        }
263    }
264
265    #[test]
266    fn test_fast_v2_binding_marker_format() {
267        let mut plugin = FastV2HydrationPlugin::new();
268        plugin.push_scope();
269        let mut writer = TestWriter::new();
270        plugin.on_binding_start("userName", &mut writer).unwrap();
271        plugin.on_binding_end("userName", &mut writer).unwrap();
272        assert_eq!(
273            writer.output,
274            "<!--fe-b$$start$$0$$userName$$fe-b--><!--fe-b$$end$$0$$userName$$fe-b-->"
275        );
276    }
277
278    #[test]
279    fn test_fast_v2_binding_sequence_uses_indexes() {
280        let mut plugin = FastV2HydrationPlugin::new();
281        plugin.push_scope();
282        let mut writer = TestWriter::new();
283        plugin.on_binding_start("a", &mut writer).unwrap();
284        plugin.on_binding_end("a", &mut writer).unwrap();
285        writer.output.clear();
286        plugin.on_binding_start("b", &mut writer).unwrap();
287        assert_eq!(writer.output, "<!--fe-b$$start$$1$$b$$fe-b-->");
288    }
289
290    #[test]
291    fn test_fast_v2_repeat_marker_format() {
292        let mut plugin = FastV2HydrationPlugin::new();
293        plugin.push_scope();
294        let mut writer = TestWriter::new();
295        plugin.on_repeat_item_start(2, &mut writer).unwrap();
296        plugin.on_repeat_item_end(2, &mut writer).unwrap();
297        assert_eq!(
298            writer.output,
299            "<!--fe-repeat$$start$$2$$fe-repeat--><!--fe-repeat$$end$$2$$fe-repeat-->"
300        );
301    }
302
303    #[test]
304    fn test_fast_v2_attribute_marker_formats() {
305        let mut single = FastV2HydrationPlugin::new();
306        single.push_scope();
307        let mut writer = TestWriter::new();
308        let one = 1u32.to_le_bytes();
309        single.on_element_data(&one, &mut writer).unwrap();
310        assert_eq!(writer.output, " data-fe-b-0");
311
312        let mut multi = FastV2HydrationPlugin::new();
313        multi.push_scope();
314        writer.output.clear();
315        let three = 3u32.to_le_bytes();
316        multi.on_element_data(&three, &mut writer).unwrap();
317        assert_eq!(writer.output, " data-fe-c-0-3");
318    }
319
320    #[test]
321    fn test_fast_v2_attribute_count_advances_binding_index() {
322        let mut plugin = FastV2HydrationPlugin::new();
323        plugin.push_scope();
324        let mut writer = TestWriter::new();
325        let three = 3u32.to_le_bytes();
326        plugin.on_element_data(&three, &mut writer).unwrap();
327
328        writer.output.clear();
329        plugin.on_binding_start("next", &mut writer).unwrap();
330        assert_eq!(writer.output, "<!--fe-b$$start$$3$$next$$fe-b-->");
331    }
332
333    #[test]
334    fn test_fast_v2_root_scope_disabled() {
335        let mut plugin = FastV2HydrationPlugin::new();
336        let mut writer = TestWriter::new();
337        plugin.on_binding_start("x", &mut writer).unwrap();
338        plugin.on_binding_end("x", &mut writer).unwrap();
339        plugin.on_repeat_item_start(0, &mut writer).unwrap();
340        plugin.on_repeat_item_end(0, &mut writer).unwrap();
341        let data = 3u32.to_le_bytes();
342        plugin.on_element_data(&data, &mut writer).unwrap();
343        assert_eq!(writer.output, "");
344    }
345
346    #[test]
347    fn test_fast_v2_write_route_component_state_emits_data_state() {
348        let plugin = FastV2HydrationPlugin::new();
349        let mut writer = TestWriter::new();
350        let state = serde_json::json!({
351            "title": "Hello",
352            "items": [{"name": "A&B"}]
353        });
354
355        plugin
356            .write_route_component_state(&state, &mut writer)
357            .unwrap();
358
359        assert!(
360            writer.output.contains("data-state="),
361            "FAST v2 handler plugin should emit data-state: {}",
362            writer.output
363        );
364        assert!(
365            writer.output.contains(r#"title="Hello""#),
366            "FAST v2 handler plugin should still emit scalar attrs: {}",
367            writer.output
368        );
369    }
370}