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::{HandlerError, RenderOptions, ResponseWriter, WebUIHandler};
14use webui_protocol::WebUIProtocol;
15
16/// A simple string buffer for collecting rendered output.
17#[cfg(test)]
18struct StringWriter {
19    content: String,
20}
21
22#[cfg(test)]
23impl StringWriter {
24    fn with_capacity(cap: usize) -> Self {
25        Self {
26            content: String::with_capacity(cap),
27        }
28    }
29}
30
31#[cfg(test)]
32impl ResponseWriter for StringWriter {
33    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
34        self.content.push_str(content);
35        Ok(())
36    }
37
38    fn end(&mut self) -> webui_handler::Result<()> {
39        Ok(())
40    }
41}
42
43/// A writer that streams rendered fragments to a JavaScript callback.
44struct CallbackWriter<'a> {
45    on_chunk: &'a Function,
46}
47
48impl<'a> CallbackWriter<'a> {
49    fn new(on_chunk: &'a Function) -> Self {
50        Self { on_chunk }
51    }
52}
53
54impl ResponseWriter for CallbackWriter<'_> {
55    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
56        self.on_chunk
57            .call1(&JsValue::UNDEFINED, &JsValue::from_str(content))
58            .map(|_| ())
59            .map_err(|e| HandlerError::Writer(format!("{e:?}")))
60    }
61
62    fn end(&mut self) -> webui_handler::Result<()> {
63        Ok(())
64    }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub(crate) enum HandlerPluginKind {
69    FastV2,
70    FastV3,
71    WebUI,
72}
73
74impl HandlerPluginKind {
75    fn parse(name: &str) -> Result<Self, WasmError> {
76        match name {
77            "fast" | "fast-v2" => Ok(Self::FastV2),
78            "fast-v3" => Ok(Self::FastV3),
79            "webui" => Ok(Self::WebUI),
80            other => Err(WasmError::UnknownPlugin(other.to_string())),
81        }
82    }
83}
84
85struct WasmRenderOptions {
86    entry: String,
87    request_path: String,
88    plugin: Option<HandlerPluginKind>,
89}
90
91impl Default for WasmRenderOptions {
92    fn default() -> Self {
93        Self {
94            entry: "index.html".to_string(),
95            request_path: "/".to_string(),
96            plugin: None,
97        }
98    }
99}
100
101/// Render a pre-built WebUI protocol with state data, streaming chunks to a callback.
102///
103/// # Arguments
104///
105/// * `protocol_bytes` - Protobuf bytes of the serialized `WebUIProtocol`.
106/// * `state_json` - JSON string of the state data.
107/// * `on_chunk` - Callback invoked with each rendered HTML fragment.
108/// * `options` - Optional object with `entry`, `requestPath`, and `plugin` fields.
109///
110/// # Returns
111///
112/// Nothing on success, or throws a JS error on failure.
113#[wasm_bindgen]
114pub fn render(
115    protocol_bytes: &[u8],
116    state_json: &str,
117    on_chunk: &Function,
118    options: Option<Object>,
119) -> Result<(), JsValue> {
120    let options = parse_render_options(options).map_err(|e| JsValue::from_str(&e.to_string()))?;
121    render_stream_inner(protocol_bytes, state_json, on_chunk, &options)
122        .map_err(|e| JsValue::from_str(&e.to_string()))
123}
124
125/// Produce a complete JSON partial response for client-side navigation.
126///
127/// Combines application state, route templates, inventory, request path, and
128/// matched route chain into a single JSON string:
129/// `{"state":{...},"templates":[...],"inventory":"...","path":"...","chain":[...]}`.
130///
131/// Host servers return this directly - no assembly required.
132#[wasm_bindgen]
133pub fn render_partial(
134    protocol_bytes: &[u8],
135    state_json: &str,
136    entry_id: &str,
137    request_path: &str,
138    inventory_hex: &str,
139) -> Result<String, JsValue> {
140    let protocol = WebUIProtocol::from_protobuf(protocol_bytes)
141        .map_err(|e| JsValue::from_str(&format!("Protocol error: {e}")))?;
142
143    let state: serde_json::Value = serde_json::from_str(state_json)
144        .map_err(|e| JsValue::from_str(&format!("invalid state JSON: {e}")))?;
145
146    let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
147
148    let mut result = webui_handler::route_handler::render_partial(
149        &protocol,
150        entry_id,
151        request_path,
152        inventory_hex,
153        &mut index,
154    )
155    .map_err(|e| JsValue::from_str(&format!("render_partial failed: {e}")))?;
156    if let Some(obj) = result.as_object_mut() {
157        obj.insert("state".into(), state);
158    }
159
160    serde_json::to_string(&result)
161        .map_err(|e| JsValue::from_str(&format!("JSON serialize error: {e}")))
162}
163
164/// Extract the CSS token name list from protocol protobuf bytes.
165///
166/// Returns a JavaScript array of token name strings, preserving the original
167/// order from the build step.
168#[wasm_bindgen]
169pub fn protocol_tokens(protocol_bytes: &[u8]) -> Result<JsValue, JsValue> {
170    let protocol = WebUIProtocol::from_protobuf(protocol_bytes)
171        .map_err(|e| JsValue::from_str(&format!("Protocol error: {e}")))?;
172
173    serde_wasm_bindgen::to_value(&protocol.tokens)
174        .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
175}
176
177/// Return component template payloads for requested component tags.
178#[wasm_bindgen]
179pub fn render_component_templates(
180    protocol_bytes: &[u8],
181    component_tags_json: &str,
182    inventory_hex: &str,
183) -> Result<String, JsValue> {
184    let protocol = WebUIProtocol::from_protobuf(protocol_bytes)
185        .map_err(|e| JsValue::from_str(&format!("Protocol error: {e}")))?;
186
187    let tags: Vec<String> = serde_json::from_str(component_tags_json)
188        .map_err(|e| JsValue::from_str(&format!("invalid tags JSON: {e}")))?;
189    let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
190
191    let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
192
193    let result = webui_handler::route_handler::render_component_templates(
194        &protocol,
195        &tag_refs,
196        inventory_hex,
197        &mut index,
198    )
199    .map_err(|e| JsValue::from_str(&format!("render_component_templates failed: {e}")))?;
200
201    serde_json::to_string(&result)
202        .map_err(|e| JsValue::from_str(&format!("JSON serialize error: {e}")))
203}
204
205fn render_stream_inner(
206    protocol_bytes: &[u8],
207    state_json: &str,
208    on_chunk: &Function,
209    options: &WasmRenderOptions,
210) -> Result<(), WasmError> {
211    let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
212    render_protocol_to_callback(&protocol, state_json, options, on_chunk)
213}
214
215#[cfg(test)]
216pub(crate) fn render_protocol_to_string(
217    protocol: &WebUIProtocol,
218    state_json: &str,
219    entry: &str,
220    request_path: &str,
221    plugin: Option<HandlerPluginKind>,
222) -> Result<String, WasmError> {
223    let state: Value = serde_json::from_str(state_json).map_err(WasmError::State)?;
224
225    let mut writer = StringWriter::with_capacity(1024);
226    let handler = create_handler(plugin);
227    handler.render(
228        protocol,
229        &state,
230        &RenderOptions::new(entry, request_path),
231        &mut writer,
232    )?;
233
234    Ok(writer.content)
235}
236
237fn render_protocol_to_callback(
238    protocol: &WebUIProtocol,
239    state_json: &str,
240    options: &WasmRenderOptions,
241    on_chunk: &Function,
242) -> Result<(), WasmError> {
243    let state: Value = serde_json::from_str(state_json).map_err(WasmError::State)?;
244
245    let mut writer = CallbackWriter::new(on_chunk);
246    let handler = create_handler(options.plugin);
247    handler.render(
248        protocol,
249        &state,
250        &RenderOptions::new(&options.entry, &options.request_path),
251        &mut writer,
252    )?;
253
254    Ok(())
255}
256
257pub(crate) fn parse_optional_plugin(
258    plugin: Option<&str>,
259) -> Result<Option<HandlerPluginKind>, WasmError> {
260    plugin.map(HandlerPluginKind::parse).transpose()
261}
262
263fn parse_render_options(options: Option<Object>) -> Result<WasmRenderOptions, WasmError> {
264    let mut parsed = WasmRenderOptions::default();
265    let Some(options) = options else {
266        return Ok(parsed);
267    };
268
269    if let Some(entry) = optional_string_field(options.as_ref(), "entry")? {
270        parsed.entry = entry;
271    }
272    if let Some(request_path) = optional_string_field(options.as_ref(), "requestPath")? {
273        parsed.request_path = request_path;
274    }
275    let plugin = optional_string_field(options.as_ref(), "plugin")?;
276    parsed.plugin = parse_optional_plugin(plugin.as_deref())?;
277
278    Ok(parsed)
279}
280
281fn optional_string_field(options: &JsValue, field: &str) -> Result<Option<String>, WasmError> {
282    let value = Reflect::get(options, &JsValue::from_str(field)).map_err(|_| {
283        WasmError::InvalidOptions(format!("failed to read `{field}` from options object"))
284    })?;
285    if value.is_null() || value.is_undefined() {
286        return Ok(None);
287    }
288    value.as_string().map(Some).ok_or_else(|| {
289        WasmError::InvalidOptions(format!("`{field}` must be a string when provided"))
290    })
291}
292
293fn create_handler(plugin: Option<HandlerPluginKind>) -> WebUIHandler {
294    match plugin {
295        Some(HandlerPluginKind::FastV2) => {
296            WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
297        }
298        Some(HandlerPluginKind::FastV3) => {
299            WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
300        }
301        Some(HandlerPluginKind::WebUI) => {
302            WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
303        }
304        None => WebUIHandler::new(),
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn parse_plugin_keeps_fast_aliases_parser_free() {
314        assert_eq!(
315            parse_optional_plugin(Some("fast")).unwrap(),
316            Some(HandlerPluginKind::FastV2)
317        );
318        assert_eq!(
319            parse_optional_plugin(Some("fast-v2")).unwrap(),
320            Some(HandlerPluginKind::FastV2)
321        );
322        assert_eq!(
323            parse_optional_plugin(Some("fast-v3")).unwrap(),
324            Some(HandlerPluginKind::FastV3)
325        );
326        assert_eq!(
327            parse_optional_plugin(Some("webui")).unwrap(),
328            Some(HandlerPluginKind::WebUI)
329        );
330    }
331
332    #[test]
333    fn parse_plugin_rejects_unknown_names() {
334        let err = parse_optional_plugin(Some("unknown")).unwrap_err();
335        assert_eq!(
336            err.to_string(),
337            "Unknown plugin: unknown. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
338        );
339    }
340}