Skip to main content

webui_wasm/
handler.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Handler-only WASM exports.
5
6use crate::error::WasmError;
7use js_sys::{Function, Object, Reflect};
8use serde_json::Value;
9use wasm_bindgen::prelude::*;
10use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
11use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
12use webui_handler::plugin::webui::WebUIHydrationPlugin;
13use webui_handler::{
14    HandlerError, Protocol as HandlerProtocol, RenderOptions, ResponseWriter, WebUIHandler,
15};
16#[cfg(test)]
17use webui_protocol::WebUIProtocol;
18
19const STREAM_CHUNK_SIZE: usize = 16 * 1024;
20
21/// A string buffer for collecting rendered output.
22struct StringWriter {
23    content: String,
24}
25
26impl StringWriter {
27    fn with_capacity(cap: usize) -> Self {
28        Self {
29            content: String::with_capacity(cap),
30        }
31    }
32}
33
34impl ResponseWriter for StringWriter {
35    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
36        self.content.push_str(content);
37        Ok(())
38    }
39
40    fn end(&mut self) -> webui_handler::Result<()> {
41        Ok(())
42    }
43}
44
45/// A writer that batches rendered fragments before crossing into JavaScript.
46struct CallbackWriter<'a> {
47    on_chunk: &'a Function,
48    buffer: String,
49}
50
51impl<'a> CallbackWriter<'a> {
52    fn new(on_chunk: &'a Function) -> Self {
53        Self {
54            on_chunk,
55            buffer: String::with_capacity(STREAM_CHUNK_SIZE),
56        }
57    }
58
59    fn flush(&mut self) -> webui_handler::Result<()> {
60        if self.buffer.is_empty() {
61            return Ok(());
62        }
63
64        let chunk = std::mem::replace(&mut self.buffer, String::with_capacity(STREAM_CHUNK_SIZE));
65        self.on_chunk
66            .call1(&JsValue::UNDEFINED, &JsValue::from_str(&chunk))
67            .map(|_| ())
68            .map_err(|error| HandlerError::Writer(format!("{error:?}")))
69    }
70}
71
72impl ResponseWriter for CallbackWriter<'_> {
73    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
74        self.buffer.push_str(content);
75        if self.buffer.len() >= STREAM_CHUNK_SIZE {
76            self.flush()?;
77        }
78        Ok(())
79    }
80
81    fn end(&mut self) -> webui_handler::Result<()> {
82        self.flush()
83    }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub(crate) enum HandlerPluginKind {
88    FastV2,
89    FastV3,
90    WebUI,
91}
92
93impl HandlerPluginKind {
94    fn parse(name: &str) -> Result<Self, WasmError> {
95        match name {
96            "fast" | "fast-v2" => Ok(Self::FastV2),
97            "fast-v3" => Ok(Self::FastV3),
98            "webui" => Ok(Self::WebUI),
99            other => Err(WasmError::UnknownPlugin(other.to_string())),
100        }
101    }
102}
103
104struct WasmRenderOptions {
105    entry: String,
106    request_path: String,
107}
108
109impl Default for WasmRenderOptions {
110    fn default() -> Self {
111        Self {
112            entry: "index.html".to_string(),
113            request_path: "/".to_string(),
114        }
115    }
116}
117
118/// A decoded protocol with reusable indices for repeated WASM renders.
119#[wasm_bindgen]
120pub struct Protocol {
121    inner: HandlerProtocol,
122    handler: WebUIHandler,
123}
124
125#[wasm_bindgen]
126impl Protocol {
127    /// Decode protobuf bytes once for repeated rendering.
128    #[wasm_bindgen(constructor)]
129    pub fn new(protocol_bytes: &[u8], plugin: Option<String>) -> Result<Protocol, JsValue> {
130        let plugin = parse_optional_plugin(plugin.as_deref())
131            .map_err(|error| JsValue::from_str(&error.to_string()))?;
132        let inner = HandlerProtocol::from_protobuf(protocol_bytes)
133            .map_err(|error| JsValue::from_str(&format!("Protocol error: {error}")))?;
134        Ok(Self {
135            inner,
136            handler: create_handler(plugin),
137        })
138    }
139
140    /// Render from an existing JSON string.
141    #[wasm_bindgen(js_name = render)]
142    pub fn render(&self, state_json: &str, options: Option<Object>) -> Result<String, JsValue> {
143        let options =
144            parse_render_options(options).map_err(|error| JsValue::from_str(&error.to_string()))?;
145        let state =
146            parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))?;
147        render_protocol_to_string_value(&self.handler, &self.inner, &state, &options)
148            .map_err(|error| JsValue::from_str(&error.to_string()))
149    }
150
151    /// Stream from an existing JSON string in bounded chunks.
152    #[wasm_bindgen(js_name = renderStream)]
153    pub fn render_stream(
154        &self,
155        state_json: &str,
156        on_chunk: &Function,
157        options: Option<Object>,
158    ) -> Result<(), JsValue> {
159        let options =
160            parse_render_options(options).map_err(|error| JsValue::from_str(&error.to_string()))?;
161        let state =
162            parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))?;
163        render_protocol_to_callback_value(&self.handler, &self.inner, &state, &options, on_chunk)
164            .map_err(|error| JsValue::from_str(&error.to_string()))
165    }
166
167    /// Produce a complete partial-navigation response.
168    #[wasm_bindgen(js_name = renderPartial)]
169    pub fn render_partial(
170        &self,
171        state_json: &str,
172        entry_id: &str,
173        request_path: &str,
174        inventory_hex: &str,
175    ) -> Result<String, JsValue> {
176        self.inner
177            .render_partial(state_json, entry_id, request_path, inventory_hex)
178            .map_err(|error| JsValue::from_str(&format!("render_partial failed: {error}")))
179    }
180
181    /// Return component template payloads for requested component tags.
182    #[wasm_bindgen(js_name = renderComponentTemplates)]
183    pub fn render_component_templates(
184        &self,
185        component_tags: JsValue,
186        inventory_hex: &str,
187    ) -> Result<String, JsValue> {
188        let tags: Vec<String> = serde_wasm_bindgen::from_value(component_tags)
189            .map_err(|error| JsValue::from_str(&format!("invalid component tags: {error}")))?;
190        let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
191        let result = self
192            .inner
193            .render_component_templates(&tag_refs, inventory_hex)
194            .map_err(|error| {
195                JsValue::from_str(&format!("render_component_templates failed: {error}"))
196            })?;
197        serde_json::to_string(&result)
198            .map_err(|error| JsValue::from_str(&format!("JSON serialize error: {error}")))
199    }
200
201    /// Return CSS token names in build order.
202    #[wasm_bindgen(js_name = tokens)]
203    pub fn tokens(&self) -> Result<JsValue, JsValue> {
204        serde_wasm_bindgen::to_value(self.inner.tokens())
205            .map_err(|error| JsValue::from_str(&format!("Serialization error: {error}")))
206    }
207}
208
209#[cfg(test)]
210pub(crate) fn render_protocol_to_string(
211    protocol: &WebUIProtocol,
212    state_json: &str,
213    entry: &str,
214    request_path: &str,
215    plugin: Option<HandlerPluginKind>,
216) -> Result<String, WasmError> {
217    let state = parse_state_json(state_json)?;
218    let options = WasmRenderOptions {
219        entry: entry.to_string(),
220        request_path: request_path.to_string(),
221    };
222    let protocol = HandlerProtocol::new(protocol.clone());
223    let handler = create_handler(plugin);
224    render_protocol_to_string_value(&handler, &protocol, &state, &options)
225}
226
227fn parse_state_json(state_json: &str) -> Result<Value, WasmError> {
228    serde_json::from_str(state_json).map_err(WasmError::State)
229}
230
231fn render_protocol_to_string_value(
232    handler: &WebUIHandler,
233    protocol: &HandlerProtocol,
234    state: &Value,
235    options: &WasmRenderOptions,
236) -> Result<String, WasmError> {
237    let mut writer = StringWriter::with_capacity(4096);
238    handler.render(
239        protocol,
240        state,
241        &RenderOptions::new(&options.entry, &options.request_path),
242        &mut writer,
243    )?;
244    Ok(writer.content)
245}
246
247fn render_protocol_to_callback_value(
248    handler: &WebUIHandler,
249    protocol: &HandlerProtocol,
250    state: &Value,
251    options: &WasmRenderOptions,
252    on_chunk: &Function,
253) -> Result<(), WasmError> {
254    let mut writer = CallbackWriter::new(on_chunk);
255    handler.render(
256        protocol,
257        state,
258        &RenderOptions::new(&options.entry, &options.request_path),
259        &mut writer,
260    )?;
261    writer.flush()?;
262    Ok(())
263}
264
265pub(crate) fn parse_optional_plugin(
266    plugin: Option<&str>,
267) -> Result<Option<HandlerPluginKind>, WasmError> {
268    plugin.map(HandlerPluginKind::parse).transpose()
269}
270
271fn parse_render_options(options: Option<Object>) -> Result<WasmRenderOptions, WasmError> {
272    let mut parsed = WasmRenderOptions::default();
273    let Some(options) = options else {
274        return Ok(parsed);
275    };
276
277    if let Some(entry) = optional_string_field(options.as_ref(), "entry")? {
278        parsed.entry = entry;
279    }
280    if let Some(request_path) = optional_string_field(options.as_ref(), "requestPath")? {
281        parsed.request_path = request_path;
282    }
283    Ok(parsed)
284}
285
286fn optional_string_field(options: &JsValue, field: &str) -> Result<Option<String>, WasmError> {
287    let value = Reflect::get(options, &JsValue::from_str(field)).map_err(|_| {
288        WasmError::InvalidOptions(format!("failed to read `{field}` from options object"))
289    })?;
290    if value.is_null() || value.is_undefined() {
291        return Ok(None);
292    }
293    value.as_string().map(Some).ok_or_else(|| {
294        WasmError::InvalidOptions(format!("`{field}` must be a string when provided"))
295    })
296}
297
298fn create_handler(plugin: Option<HandlerPluginKind>) -> WebUIHandler {
299    match plugin {
300        Some(HandlerPluginKind::FastV2) => {
301            WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
302        }
303        Some(HandlerPluginKind::FastV3) => {
304            WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
305        }
306        Some(HandlerPluginKind::WebUI) => {
307            WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
308        }
309        None => WebUIHandler::new(),
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn parse_plugin_keeps_fast_aliases_parser_free() {
319        assert_eq!(
320            parse_optional_plugin(Some("fast")).unwrap(),
321            Some(HandlerPluginKind::FastV2)
322        );
323        assert_eq!(
324            parse_optional_plugin(Some("fast-v2")).unwrap(),
325            Some(HandlerPluginKind::FastV2)
326        );
327        assert_eq!(
328            parse_optional_plugin(Some("fast-v3")).unwrap(),
329            Some(HandlerPluginKind::FastV3)
330        );
331        assert_eq!(
332            parse_optional_plugin(Some("webui")).unwrap(),
333            Some(HandlerPluginKind::WebUI)
334        );
335    }
336
337    #[test]
338    fn parse_plugin_rejects_unknown_names() {
339        let err = parse_optional_plugin(Some("unknown")).unwrap_err();
340        assert_eq!(
341            err.to_string(),
342            "Unknown plugin: unknown. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
343        );
344    }
345
346    #[test]
347    fn protocol_reuses_decoded_protocol() {
348        use std::collections::HashMap;
349        use webui_protocol::{FragmentList, WebUIFragment};
350
351        let mut fragments = HashMap::new();
352        fragments.insert(
353            "index.html".to_string(),
354            FragmentList {
355                fragments: vec![WebUIFragment::signal("name".to_string(), true)],
356            },
357        );
358        let bytes = WebUIProtocol::new(fragments)
359            .to_protobuf()
360            .expect("protocol should serialize");
361        let protocol = Protocol::new(&bytes, None).expect("protocol should load");
362
363        let first = protocol
364            .render(r#"{"name":"first"}"#, None)
365            .expect("first render should succeed");
366        let second = protocol
367            .render(r#"{"name":"second"}"#, None)
368            .expect("second render should succeed");
369
370        assert_eq!(first, "first");
371        assert_eq!(second, "second");
372    }
373
374    #[test]
375    fn render_projects_state_to_component_hydration_keys() {
376        use std::collections::HashMap;
377        use webui_protocol::{
378            ComponentData, FragmentList, InitialStateStrategy, StateProjectionMode, WebUIFragment,
379        };
380
381        let mut fragments = HashMap::new();
382        fragments.insert(
383            "index.html".to_string(),
384            FragmentList {
385                fragments: vec![
386                    WebUIFragment::raw("<html><head>"),
387                    WebUIFragment::signal("head_end".to_string(), true),
388                    WebUIFragment::raw("</head><body>"),
389                    WebUIFragment::component("client-card"),
390                    WebUIFragment::signal("body_end".to_string(), true),
391                    WebUIFragment::raw("</body></html>"),
392                ],
393            },
394        );
395        fragments.insert(
396            "client-card".to_string(),
397            FragmentList {
398                fragments: vec![WebUIFragment::raw("<p>client</p>")],
399            },
400        );
401        let mut protocol = WebUIProtocol::new(fragments);
402        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
403        protocol.components.insert(
404            "client-card".to_string(),
405            ComponentData {
406                hydration_mode: StateProjectionMode::Keys as i32,
407                hydration_keys: vec!["kept".to_string()],
408                ..Default::default()
409            },
410        );
411
412        let rendered = render_protocol_to_string(
413            &protocol,
414            r#"{"kept":"KEPT_VALUE_WASM","dropped":"DROPPED_VALUE_WASM"}"#,
415            "index.html",
416            "/",
417            Some(HandlerPluginKind::WebUI),
418        )
419        .expect("render should succeed");
420
421        // Only the hydratable key reaches the bootstrap state block...
422        assert!(
423            rendered.contains(r#""kept":"KEPT_VALUE_WASM""#),
424            "hydratable key missing from bootstrap state:\n{rendered}"
425        );
426        // ...the non-hydratable key is projected out entirely.
427        assert!(
428            !rendered.contains("DROPPED_VALUE_WASM"),
429            "server-only value leaked into render:\n{rendered}"
430        );
431        assert!(
432            !rendered.contains("dropped"),
433            "server-only key name leaked into render:\n{rendered}"
434        );
435    }
436}