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 std::sync::Arc;
10use wasm_bindgen::prelude::*;
11use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
12use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
13use webui_handler::plugin::webui::WebUIHydrationPlugin;
14use webui_handler::{
15    BoundaryId, BoundaryMode, HandlerError, Protocol as HandlerProtocol, RenderOptions,
16    ResponseWriter, SessionOptions, StreamingSession as HandlerStreamingSession, WebUIHandler,
17};
18#[cfg(test)]
19use webui_protocol::WebUIProtocol;
20
21const STREAM_CHUNK_SIZE: usize = 16 * 1024;
22
23/// A string buffer for collecting rendered output.
24struct StringWriter {
25    content: String,
26}
27
28impl StringWriter {
29    fn with_capacity(cap: usize) -> Self {
30        Self {
31            content: String::with_capacity(cap),
32        }
33    }
34}
35
36impl ResponseWriter for StringWriter {
37    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
38        self.content.push_str(content);
39        Ok(())
40    }
41
42    fn end(&mut self) -> webui_handler::Result<()> {
43        Ok(())
44    }
45}
46
47/// A writer that batches rendered fragments before crossing into JavaScript.
48struct CallbackWriter<'a> {
49    on_chunk: &'a Function,
50    buffer: String,
51}
52
53impl<'a> CallbackWriter<'a> {
54    fn new(on_chunk: &'a Function) -> Self {
55        Self {
56            on_chunk,
57            buffer: String::with_capacity(STREAM_CHUNK_SIZE),
58        }
59    }
60
61    fn flush(&mut self) -> webui_handler::Result<()> {
62        if self.buffer.is_empty() {
63            return Ok(());
64        }
65
66        let chunk = std::mem::replace(&mut self.buffer, String::with_capacity(STREAM_CHUNK_SIZE));
67        self.on_chunk
68            .call1(&JsValue::UNDEFINED, &JsValue::from_str(&chunk))
69            .map(|_| ())
70            .map_err(|error| HandlerError::Writer(format!("{error:?}")))
71    }
72}
73
74impl ResponseWriter for CallbackWriter<'_> {
75    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
76        self.buffer.push_str(content);
77        if self.buffer.len() >= STREAM_CHUNK_SIZE {
78            self.flush()?;
79        }
80        Ok(())
81    }
82
83    fn end(&mut self) -> webui_handler::Result<()> {
84        self.flush()
85    }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub(crate) enum HandlerPluginKind {
90    FastV2,
91    FastV3,
92    WebUI,
93}
94
95impl HandlerPluginKind {
96    fn parse(name: &str) -> Result<Self, WasmError> {
97        match name {
98            "fast" | "fast-v2" => Ok(Self::FastV2),
99            "fast-v3" => Ok(Self::FastV3),
100            "webui" => Ok(Self::WebUI),
101            other => Err(WasmError::UnknownPlugin(other.to_string())),
102        }
103    }
104}
105
106struct WasmRenderOptions {
107    entry: String,
108    request_path: String,
109}
110
111impl Default for WasmRenderOptions {
112    fn default() -> Self {
113        Self {
114            entry: "index.html".to_string(),
115            request_path: "/".to_string(),
116        }
117    }
118}
119
120/// A decoded protocol with reusable indices for repeated WASM renders.
121#[wasm_bindgen]
122pub struct Protocol {
123    inner: Arc<HandlerProtocol>,
124    handler: Arc<WebUIHandler>,
125}
126
127#[wasm_bindgen]
128impl Protocol {
129    /// Decode protobuf bytes once for repeated rendering.
130    #[wasm_bindgen(constructor)]
131    pub fn new(protocol_bytes: &[u8], plugin: Option<String>) -> Result<Protocol, JsValue> {
132        let plugin = parse_optional_plugin(plugin.as_deref())
133            .map_err(|error| JsValue::from_str(&error.to_string()))?;
134        let inner = HandlerProtocol::from_protobuf(protocol_bytes)
135            .map_err(|error| JsValue::from_str(&format!("Protocol error: {error}")))?;
136        Ok(Self {
137            inner: Arc::new(inner),
138            handler: Arc::new(create_handler(plugin)),
139        })
140    }
141
142    /// Render from an existing JSON string.
143    #[wasm_bindgen(js_name = render)]
144    pub fn render(&self, state_json: &str, options: Option<Object>) -> Result<String, JsValue> {
145        let options =
146            parse_render_options(options).map_err(|error| JsValue::from_str(&error.to_string()))?;
147        let state =
148            parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))?;
149        render_protocol_to_string_value(&self.handler, &self.inner, &state, &options)
150            .map_err(|error| JsValue::from_str(&error.to_string()))
151    }
152
153    /// Stream from an existing JSON string in bounded chunks.
154    #[wasm_bindgen(js_name = renderStream)]
155    pub fn render_stream(
156        &self,
157        state_json: &str,
158        on_chunk: &Function,
159        options: Option<Object>,
160    ) -> Result<(), JsValue> {
161        let options =
162            parse_render_options(options).map_err(|error| JsValue::from_str(&error.to_string()))?;
163        let state =
164            parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))?;
165        render_protocol_to_callback_value(&self.handler, &self.inner, &state, &options, on_chunk)
166            .map_err(|error| JsValue::from_str(&error.to_string()))
167    }
168
169    /// Produce a complete partial-navigation response.
170    #[wasm_bindgen(js_name = renderPartial)]
171    pub fn render_partial(
172        &self,
173        state_json: &str,
174        entry_id: &str,
175        request_path: &str,
176        inventory_hex: &str,
177    ) -> Result<String, JsValue> {
178        self.inner
179            .render_partial(state_json, entry_id, request_path, inventory_hex)
180            .map_err(|error| JsValue::from_str(&format!("render_partial failed: {error}")))
181    }
182
183    /// Return component template payloads for requested component tags.
184    #[wasm_bindgen(js_name = renderComponentTemplates)]
185    pub fn render_component_templates(
186        &self,
187        component_tags: JsValue,
188        inventory_hex: &str,
189    ) -> Result<String, JsValue> {
190        let tags: Vec<String> = serde_wasm_bindgen::from_value(component_tags)
191            .map_err(|error| JsValue::from_str(&format!("invalid component tags: {error}")))?;
192        let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
193        let result = self
194            .inner
195            .render_component_templates(&tag_refs, inventory_hex)
196            .map_err(|error| {
197                JsValue::from_str(&format!("render_component_templates failed: {error}"))
198            })?;
199        serde_json::to_string(&result)
200            .map_err(|error| JsValue::from_str(&format!("JSON serialize error: {error}")))
201    }
202
203    /// Return CSS token names in build order.
204    #[wasm_bindgen(js_name = tokens)]
205    pub fn tokens(&self) -> Result<JsValue, JsValue> {
206        serde_wasm_bindgen::to_value(self.inner.tokens())
207            .map_err(|error| JsValue::from_str(&format!("Serialization error: {error}")))
208    }
209
210    /// Open a host-driven progressive response for a streaming entry.
211    ///
212    /// Unlike `renderStream`, which pushes every chunk through one callback
213    /// during a single synchronous call, the returned session hands each chunk
214    /// back so the host owns the socket, the write order, and backpressure.
215    #[wasm_bindgen(js_name = streamResponse)]
216    pub fn stream_response(
217        &self,
218        entry: String,
219        request_path: String,
220        options: Option<Object>,
221    ) -> Result<StreamingSession, JsValue> {
222        let mut session_options = SessionOptions::new(entry, request_path);
223        if let Some(options) = options {
224            session_options.nonce = optional_string_property(&options, "nonce")?;
225            session_options.head_inject = optional_string_property(&options, "headInject")?;
226            session_options.body_inject = optional_string_property(&options, "bodyInject")?;
227        }
228
229        HandlerStreamingSession::new(
230            Arc::clone(&self.handler),
231            Arc::clone(&self.inner),
232            session_options,
233        )
234        .map(|inner| StreamingSession { inner })
235        .map_err(streaming_error)
236    }
237}
238
239/// A progressive HTML response driven one chunk at a time from JavaScript.
240///
241/// Every method returns the UTF-8 bytes it produced. Write them to the
242/// response and apply the host's own backpressure; the session holds no
243/// transport and never blocks on one.
244///
245/// ```js
246/// const session = protocol.streamResponse('index.html', '/');
247/// const weather = session.boundary('weather-shell');
248/// controller.enqueue(session.writeShell(shellState));
249/// controller.enqueue(session.writeBoundary(weather, weatherState, 'updatable'));
250/// controller.enqueue(session.update(weather, forecast));
251/// controller.enqueue(session.finish(tailState));
252/// ```
253#[wasm_bindgen]
254pub struct StreamingSession {
255    inner: HandlerStreamingSession,
256}
257
258#[wasm_bindgen]
259impl StreamingSession {
260    /// Resolve an authored boundary name to a stable integer handle.
261    ///
262    /// Resolve once outside the write loop; the handle costs nothing to reuse.
263    #[wasm_bindgen(js_name = boundary)]
264    pub fn boundary(&self, name: &str) -> Result<u32, JsValue> {
265        self.inner
266            .boundary(name)
267            .map(BoundaryId::raw)
268            .map_err(streaming_error)
269    }
270
271    /// Number of compile-time boundaries declared by this entry.
272    #[wasm_bindgen(getter, js_name = boundaryCount)]
273    pub fn boundary_count(&self) -> u32 {
274        // Boundary counts are bounded by the compiled entry, so this cannot
275        // exceed u32 in any protocol the build can produce.
276        u32::try_from(self.inner.boundary_count()).unwrap_or(u32::MAX)
277    }
278
279    /// Whether the terminal record has been written.
280    #[wasm_bindgen(getter, js_name = finished)]
281    pub fn finished(&self) -> bool {
282        self.inner.is_finished()
283    }
284
285    /// Render everything before the first boundary.
286    #[wasm_bindgen(js_name = writeShell)]
287    pub fn write_shell(&mut self, state_json: &str) -> Result<Vec<u8>, JsValue> {
288        let state = session_state(state_json)?;
289
290        self.inner.write_shell(&state).map_err(streaming_error)
291    }
292
293    /// Render and commit the next boundary in declaration order.
294    ///
295    /// `mode` is `"final"` (default) or `"updatable"`. Only updatable
296    /// boundaries accept later `update()` calls.
297    #[wasm_bindgen(js_name = writeBoundary)]
298    pub fn write_boundary(
299        &mut self,
300        boundary: u32,
301        state_json: &str,
302        mode: Option<String>,
303    ) -> Result<Vec<u8>, JsValue> {
304        let state = session_state(state_json)?;
305
306        let mode = parse_boundary_mode(mode.as_deref())?;
307        self.inner
308            .write_boundary(BoundaryId::from_raw(boundary), &state, mode)
309            .map_err(streaming_error)
310    }
311
312    /// Push a projected state patch to a committed updatable boundary.
313    #[wasm_bindgen(js_name = update)]
314    pub fn update(&mut self, boundary: u32, state_json: &str) -> Result<Vec<u8>, JsValue> {
315        let state = session_state(state_json)?;
316
317        self.inner
318            .update(BoundaryId::from_raw(boundary), &state)
319            .map_err(streaming_error)
320    }
321
322    /// Render the document tail and emit the terminal record.
323    #[wasm_bindgen(js_name = finish)]
324    pub fn finish(&mut self, state_json: &str) -> Result<Vec<u8>, JsValue> {
325        let state = session_state(state_json)?;
326
327        self.inner.finish(&state).map_err(streaming_error)
328    }
329}
330
331fn session_state(state_json: &str) -> Result<Value, JsValue> {
332    parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))
333}
334
335fn parse_boundary_mode(mode: Option<&str>) -> Result<BoundaryMode, JsValue> {
336    match mode {
337        None | Some("final") => Ok(BoundaryMode::Final),
338        Some("updatable") => Ok(BoundaryMode::Updatable),
339        Some(other) => Err(JsValue::from_str(&format!(
340            "unknown boundary mode '{other}'; expected 'final' or 'updatable'"
341        ))),
342    }
343}
344
345fn streaming_error(error: HandlerError) -> JsValue {
346    JsValue::from_str(&error.to_string())
347}
348
349fn optional_string_property(options: &Object, key: &str) -> Result<Option<String>, JsValue> {
350    let value = Reflect::get(options, &JsValue::from_str(key))
351        .map_err(|_| JsValue::from_str(&format!("failed to read '{key}' option")))?;
352    if value.is_undefined() || value.is_null() {
353        return Ok(None);
354    }
355    value
356        .as_string()
357        .map(Some)
358        .ok_or_else(|| JsValue::from_str(&format!("'{key}' must be a string")))
359}
360
361#[cfg(test)]
362pub(crate) fn render_protocol_to_string(
363    protocol: &WebUIProtocol,
364    state_json: &str,
365    entry: &str,
366    request_path: &str,
367    plugin: Option<HandlerPluginKind>,
368) -> Result<String, WasmError> {
369    let state = parse_state_json(state_json)?;
370    let options = WasmRenderOptions {
371        entry: entry.to_string(),
372        request_path: request_path.to_string(),
373    };
374    let protocol = HandlerProtocol::new(protocol.clone());
375    let handler = create_handler(plugin);
376    render_protocol_to_string_value(&handler, &protocol, &state, &options)
377}
378
379fn parse_state_json(state_json: &str) -> Result<Value, WasmError> {
380    serde_json::from_str(state_json).map_err(WasmError::State)
381}
382
383fn render_protocol_to_string_value(
384    handler: &WebUIHandler,
385    protocol: &HandlerProtocol,
386    state: &Value,
387    options: &WasmRenderOptions,
388) -> Result<String, WasmError> {
389    let mut writer = StringWriter::with_capacity(4096);
390    handler.render(
391        protocol,
392        state,
393        &RenderOptions::new(&options.entry, &options.request_path),
394        &mut writer,
395    )?;
396    Ok(writer.content)
397}
398
399fn render_protocol_to_callback_value(
400    handler: &WebUIHandler,
401    protocol: &HandlerProtocol,
402    state: &Value,
403    options: &WasmRenderOptions,
404    on_chunk: &Function,
405) -> Result<(), WasmError> {
406    let mut writer = CallbackWriter::new(on_chunk);
407    handler.render(
408        protocol,
409        state,
410        &RenderOptions::new(&options.entry, &options.request_path),
411        &mut writer,
412    )?;
413    writer.flush()?;
414    Ok(())
415}
416
417pub(crate) fn parse_optional_plugin(
418    plugin: Option<&str>,
419) -> Result<Option<HandlerPluginKind>, WasmError> {
420    plugin.map(HandlerPluginKind::parse).transpose()
421}
422
423fn parse_render_options(options: Option<Object>) -> Result<WasmRenderOptions, WasmError> {
424    let mut parsed = WasmRenderOptions::default();
425    let Some(options) = options else {
426        return Ok(parsed);
427    };
428
429    if let Some(entry) = optional_string_field(options.as_ref(), "entry")? {
430        parsed.entry = entry;
431    }
432    if let Some(request_path) = optional_string_field(options.as_ref(), "requestPath")? {
433        parsed.request_path = request_path;
434    }
435    Ok(parsed)
436}
437
438fn optional_string_field(options: &JsValue, field: &str) -> Result<Option<String>, WasmError> {
439    let value = Reflect::get(options, &JsValue::from_str(field)).map_err(|_| {
440        WasmError::InvalidOptions(format!("failed to read `{field}` from options object"))
441    })?;
442    if value.is_null() || value.is_undefined() {
443        return Ok(None);
444    }
445    value.as_string().map(Some).ok_or_else(|| {
446        WasmError::InvalidOptions(format!("`{field}` must be a string when provided"))
447    })
448}
449
450fn create_handler(plugin: Option<HandlerPluginKind>) -> WebUIHandler {
451    match plugin {
452        Some(HandlerPluginKind::FastV2) => {
453            WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
454        }
455        Some(HandlerPluginKind::FastV3) => {
456            WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
457        }
458        Some(HandlerPluginKind::WebUI) => {
459            WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
460        }
461        None => WebUIHandler::new(),
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    fn structural_fragment(value: &str) -> webui_protocol::WebUIFragment {
470        let mut token = String::with_capacity("}}}webui:".len() + value.len());
471        token.push_str("}}}webui:");
472        token.push_str(value);
473        webui_protocol::WebUIFragment::signal(token, true)
474    }
475
476    #[test]
477    fn parse_plugin_keeps_fast_aliases_parser_free() {
478        assert_eq!(
479            parse_optional_plugin(Some("fast")).unwrap(),
480            Some(HandlerPluginKind::FastV2)
481        );
482        assert_eq!(
483            parse_optional_plugin(Some("fast-v2")).unwrap(),
484            Some(HandlerPluginKind::FastV2)
485        );
486        assert_eq!(
487            parse_optional_plugin(Some("fast-v3")).unwrap(),
488            Some(HandlerPluginKind::FastV3)
489        );
490        assert_eq!(
491            parse_optional_plugin(Some("webui")).unwrap(),
492            Some(HandlerPluginKind::WebUI)
493        );
494    }
495
496    #[test]
497    fn parse_plugin_rejects_unknown_names() {
498        let err = parse_optional_plugin(Some("unknown")).unwrap_err();
499        assert_eq!(
500            err.to_string(),
501            "Unknown plugin: unknown. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
502        );
503    }
504
505    #[test]
506    fn protocol_reuses_decoded_protocol() {
507        use std::collections::HashMap;
508        use webui_protocol::{FragmentList, WebUIFragment};
509
510        let mut fragments = HashMap::new();
511        fragments.insert(
512            "index.html".to_string(),
513            FragmentList {
514                fragments: vec![WebUIFragment::signal("name".to_string(), true)],
515            },
516        );
517        let bytes = WebUIProtocol::new(fragments)
518            .to_protobuf()
519            .expect("protocol should serialize");
520        let protocol = Protocol::new(&bytes, None).expect("protocol should load");
521
522        let first = protocol
523            .render(r#"{"name":"first"}"#, None)
524            .expect("first render should succeed");
525        let second = protocol
526            .render(r#"{"name":"second"}"#, None)
527            .expect("second render should succeed");
528
529        assert_eq!(first, "first");
530        assert_eq!(second, "second");
531    }
532
533    #[test]
534    fn render_projects_state_to_component_hydration_keys() {
535        use std::collections::HashMap;
536        use webui_protocol::{
537            ComponentData, FragmentList, InitialStateStrategy, StateProjectionMode, WebUIFragment,
538        };
539
540        let mut fragments = HashMap::new();
541        fragments.insert(
542            "index.html".to_string(),
543            FragmentList {
544                fragments: vec![
545                    WebUIFragment::raw("<html><head>"),
546                    structural_fragment("head_end"),
547                    WebUIFragment::raw("</head><body>"),
548                    WebUIFragment::component("client-card"),
549                    structural_fragment("body_end"),
550                    WebUIFragment::raw("</body></html>"),
551                ],
552            },
553        );
554        fragments.insert(
555            "client-card".to_string(),
556            FragmentList {
557                fragments: vec![WebUIFragment::raw("<p>client</p>")],
558            },
559        );
560        let mut protocol = WebUIProtocol::new(fragments);
561        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
562        protocol.components.insert(
563            "client-card".to_string(),
564            ComponentData {
565                hydration_mode: StateProjectionMode::Keys as i32,
566                hydration_keys: vec!["kept".to_string()],
567                ..Default::default()
568            },
569        );
570
571        let rendered = render_protocol_to_string(
572            &protocol,
573            r#"{"kept":"KEPT_VALUE_WASM","dropped":"DROPPED_VALUE_WASM"}"#,
574            "index.html",
575            "/",
576            Some(HandlerPluginKind::WebUI),
577        )
578        .expect("render should succeed");
579
580        // Only the hydratable key reaches the bootstrap state block...
581        assert!(
582            rendered.contains(r#""kept":"KEPT_VALUE_WASM""#),
583            "hydratable key missing from bootstrap state:\n{rendered}"
584        );
585        // ...the non-hydratable key is projected out entirely.
586        assert!(
587            !rendered.contains("DROPPED_VALUE_WASM"),
588            "server-only value leaked into render:\n{rendered}"
589        );
590        assert!(
591            !rendered.contains("dropped"),
592            "server-only key name leaked into render:\n{rendered}"
593        );
594    }
595}