Skip to main content

webui/
server.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! High-level server helper for custom Rust servers using `webui-router`.
5//!
6//! This module provides [`serve_request`] which encapsulates:
7//! - Route parameter extraction from the URL
8//! - HTML SSR rendering via [`WebUIHandler`]
9//! - JSON partial responses for client-side navigation
10//! - Template inventory management
11//!
12//! # Example
13//!
14//! ```rust,ignore
15//! use webui::{
16//!     server::{serve_request, ServeRequest, ServeResponse},
17//!     RenderOptions,
18//! };
19//!
20//! let options = RenderOptions::new("index.html", "/email/thread-5")
21//!     .with_nonce("request-csp-nonce");
22//! let request = ServeRequest::new(options, false, "");
23//!
24//! match serve_request(&protocol, &handler, state, &request) {
25//!     Ok(ServeResponse::Html(html)) => { /* serve HTML */ }
26//!     Ok(ServeResponse::Json(json)) => { /* serve JSON */ }
27//!     Err(e) => { /* handle error */ }
28//! }
29//! ```
30
31use crate::{Protocol, WebUIHandler};
32use webui_handler::route_handler;
33use webui_handler::RenderOptions;
34
35webui_handler::define_string_response_writer!(MemWriter, buf);
36
37/// A server request to be handled by [`serve_request`].
38pub struct ServeRequest<'a> {
39    render_options: RenderOptions<'a>,
40    accept_json: bool,
41    inventory_hex: &'a str,
42}
43
44impl<'a> ServeRequest<'a> {
45    /// Create a server request from the complete per-render configuration.
46    ///
47    /// Set `accept_json` when the request accepts a partial-navigation response.
48    /// `inventory_hex` is the client's `X-WebUI-Inventory` value, or an empty
49    /// string when the header is absent.
50    #[must_use]
51    #[inline]
52    pub fn new(
53        render_options: RenderOptions<'a>,
54        accept_json: bool,
55        inventory_hex: &'a str,
56    ) -> Self {
57        Self {
58            render_options,
59            accept_json,
60            inventory_hex,
61        }
62    }
63}
64
65/// The response from [`serve_request`].
66pub enum ServeResponse {
67    /// Full HTML page for initial load or browser refresh.
68    Html(String),
69    /// JSON partial for client-side navigation via `webui-router`.
70    Json(String),
71}
72
73/// Handle a server request with automatic route handling.
74///
75/// For HTML requests: renders the full page with route-matched SSR.
76/// The handler emits a consolidated `window.__webui` script block
77/// containing state, chain, inventory, and template metadata.
78///
79/// For JSON requests: returns a partial response with route-scoped state,
80/// needed templates, and inventory for the `webui-router` client.
81///
82/// # Arguments
83/// - `protocol` — The compiled WebUI protocol from [`build`](crate::build)
84/// - `handler` — The WebUI handler (with plugin configured)
85/// - `state` — The state JSON to render. For HTML requests, this should be
86///   the full app state. For JSON requests, the caller should provide
87///   route-scoped state (only what the target page component needs).
88/// - `request` — The incoming request details and complete [`RenderOptions`]
89///
90/// # Route Parameters
91/// Route parameters (`:param` in route paths) are automatically extracted
92/// and injected into the state object.
93///
94/// # Content Security Policy
95/// Set the response nonce with [`RenderOptions::with_nonce`]. Full-document
96/// rendering forwards the options unchanged so every generated inline script
97/// and the `webui-nonce` metadata receive the request nonce.
98pub fn serve_request(
99    protocol: &Protocol,
100    handler: &WebUIHandler,
101    state: serde_json::Value,
102    request: &ServeRequest<'_>,
103) -> Result<ServeResponse, String> {
104    let options = &request.render_options;
105
106    // Extract route params and inject into state
107    let params = route_handler::collect_nested_route_params(
108        protocol,
109        options.entry_id,
110        options.request_path,
111    );
112    let mut data = state;
113    if let Some(map) = data.as_object_mut() {
114        for (k, v) in &params {
115            map.insert(k.clone(), serde_json::Value::String(v.clone()));
116        }
117    }
118
119    if request.accept_json {
120        // JSON partial response for client-side navigation.
121        let state_json =
122            serde_json::to_string(&data).map_err(|e| format!("state serialization failed: {e}"))?;
123        let partial = protocol
124            .render_partial(
125                &state_json,
126                options.entry_id,
127                options.request_path,
128                request.inventory_hex,
129            )
130            .map_err(|e| format!("render_partial failed: {e}"))?;
131        Ok(ServeResponse::Json(partial))
132    } else {
133        // Full HTML SSR — handler emits the consolidated window.__webui
134        // script block (state, chain, inventory, templates) automatically.
135        let mut writer = MemWriter::with_capacity(131_072);
136        handler
137            .render(protocol, &data, options, &mut writer)
138            .map_err(|e| format!("render failed: {e}"))?;
139
140        Ok(ServeResponse::Html(writer.buf))
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use serde_json::json;
148    use std::collections::HashMap;
149    use webui_handler::plugin::webui::WebUIHydrationPlugin;
150    use webui_protocol::{
151        ComponentData, ComponentStyleClosure, CssStrategy, FragmentList, WebUIFragment,
152        WebUIProtocol,
153    };
154
155    fn structural_fragment(value: &str) -> WebUIFragment {
156        let mut signal = String::with_capacity("}}}webui:".len() + value.len());
157        signal.push_str("}}}webui:");
158        signal.push_str(value);
159        WebUIFragment::signal(signal, true)
160    }
161
162    fn add_page_style_closures(protocol: &mut WebUIProtocol) {
163        let closure = ComponentStyleClosure {
164            component_tags: vec!["my-page".to_string()],
165            style_chunks: Vec::new(),
166        };
167        protocol
168            .style_closures
169            .insert("index.html".to_string(), closure.clone());
170        protocol
171            .style_closures
172            .insert("my-page".to_string(), closure);
173    }
174
175    fn response_json(response: ServeResponse) -> serde_json::Value {
176        match response {
177            ServeResponse::Json(json) => {
178                serde_json::from_str(&json).expect("partial response should be valid JSON")
179            }
180            ServeResponse::Html(_) => panic!("expected JSON partial response"),
181        }
182    }
183
184    fn full_document_protocol() -> Protocol {
185        let fragments = HashMap::from([
186            (
187                "index.html".to_string(),
188                FragmentList {
189                    fragments: vec![
190                        WebUIFragment::raw("<!doctype html><html><head>"),
191                        structural_fragment("head_end"),
192                        WebUIFragment::raw("</head><body>"),
193                        WebUIFragment::component("my-page"),
194                        structural_fragment("body_end"),
195                        WebUIFragment::raw("</body></html>"),
196                    ],
197                    contains_boundary: false,
198                },
199            ),
200            (
201                "my-page".to_string(),
202                FragmentList {
203                    fragments: vec![WebUIFragment::raw("<main>ready</main>")],
204                    contains_boundary: false,
205                },
206            ),
207        ]);
208        let mut protocol = WebUIProtocol::new(fragments);
209        protocol.components.insert(
210            "my-page".to_string(),
211            ComponentData {
212                template_json: r#"{"h":"<main>ready</main>","th":1}"#.to_string(),
213                template_functions: "[function(){return true}]".to_string(),
214                ..Default::default()
215            },
216        );
217        Protocol::new(protocol)
218    }
219
220    #[test]
221    fn serve_request_html_forwards_complete_render_options() {
222        let protocol = full_document_protocol();
223        let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
224        let request = ServeRequest::new(
225            RenderOptions::new("index.html", "/")
226                .with_nonce("request-nonce")
227                .with_head_inject("<meta name=\"host-head\">")
228                .with_body_inject("<script src=\"host.js\"></script>"),
229            false,
230            "",
231        );
232
233        let response = serve_request(&protocol, &handler, json!({}), &request)
234            .expect("full-document response should succeed");
235        let html = match response {
236            ServeResponse::Html(html) => html,
237            ServeResponse::Json(_) => panic!("expected full-document response"),
238        };
239
240        let mut direct = MemWriter::with_capacity(131_072);
241        handler
242            .render(&protocol, &json!({}), &request.render_options, &mut direct)
243            .expect("direct render should succeed");
244
245        assert_eq!(html, direct.buf);
246        assert!(html.contains(r#"<meta name="webui-nonce" content="request-nonce">"#));
247        assert!(html.contains(r#"<script nonce="request-nonce">(function(){var w=window.__webui"#));
248        assert!(html.contains(r#"<meta name="host-head">"#));
249        assert!(html.contains(r#"<script src="host.js"></script>"#));
250    }
251
252    #[test]
253    fn serve_request_json_partial_carries_module_component_styles() {
254        let mut fragments = HashMap::new();
255        fragments.insert(
256            "index.html".to_string(),
257            FragmentList {
258                fragments: vec![WebUIFragment::component("my-page")],
259                contains_boundary: false,
260            },
261        );
262        fragments.insert(
263            "my-page".to_string(),
264            FragmentList {
265                fragments: vec![WebUIFragment::raw("<p>page</p>")],
266                contains_boundary: false,
267            },
268        );
269
270        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
271        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
272        let component = protocol
273            .components
274            .entry("my-page".to_string())
275            .or_default();
276        component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
277        component.css = ".page{color:red}".to_string();
278        add_page_style_closures(&mut protocol);
279        let protocol = Protocol::new(protocol);
280
281        let handler = WebUIHandler::new();
282        let request = ServeRequest::new(RenderOptions::new("index.html", "/"), true, "");
283
284        let response = serve_request(&protocol, &handler, json!({}), &request)
285            .expect("partial response should succeed");
286
287        let json = response_json(response);
288
289        assert_eq!(
290            json["componentStyles"]["resources"]["my-page"],
291            json!({
292                "kind": "module",
293                "specifier": "my-page",
294                "css": ".page{color:red}"
295            })
296        );
297        // templates must not contain any style tags
298        assert!(
299            !json["templates"]["my-page"]["h"]
300                .as_str()
301                .unwrap_or_default()
302                .contains("<style"),
303            "template metadata should not contain module style tags"
304        );
305    }
306
307    #[test]
308    fn serve_request_link_strategy_returns_component_styles() {
309        let mut fragments = HashMap::new();
310        fragments.insert(
311            "index.html".to_string(),
312            FragmentList {
313                fragments: vec![WebUIFragment::component("my-page")],
314                contains_boundary: false,
315            },
316        );
317        fragments.insert(
318            "my-page".to_string(),
319            FragmentList {
320                fragments: vec![WebUIFragment::raw("<p>page</p>")],
321                contains_boundary: false,
322            },
323        );
324
325        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
326        let comp = protocol
327            .components
328            .entry("my-page".to_string())
329            .or_default();
330        comp.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
331        comp.css_href = "my-page.css".to_string();
332        add_page_style_closures(&mut protocol);
333        let protocol = Protocol::new(protocol);
334
335        let handler = WebUIHandler::new();
336        let request = ServeRequest::new(RenderOptions::new("index.html", "/"), true, "");
337
338        let response = serve_request(&protocol, &handler, json!({}), &request)
339            .expect("partial response should succeed");
340
341        let json = response_json(response);
342
343        assert_eq!(
344            json["componentStyles"]["resources"]["my-page"],
345            json!({"kind": "link", "href": "my-page.css"})
346        );
347        assert!(
348            json["templates"].as_object().is_some_and(|a| a.len() == 1),
349            "Link strategy should still return templates"
350        );
351    }
352
353    #[test]
354    fn serve_request_style_strategy_returns_component_styles() {
355        let mut fragments = HashMap::new();
356        fragments.insert(
357            "index.html".to_string(),
358            FragmentList {
359                fragments: vec![WebUIFragment::component("my-page")],
360                contains_boundary: false,
361            },
362        );
363        fragments.insert(
364            "my-page".to_string(),
365            FragmentList {
366                fragments: vec![WebUIFragment::raw("<p>page</p>")],
367                contains_boundary: false,
368            },
369        );
370
371        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
372        protocol.set_css_strategy(CssStrategy::Style);
373        let comp = protocol
374            .components
375            .entry("my-page".to_string())
376            .or_default();
377        comp.template_json = r#"{"h":"<p/>"}"#.to_string();
378        comp.css = ".p{color:red}".to_string();
379        add_page_style_closures(&mut protocol);
380        let protocol = Protocol::new(protocol);
381
382        let handler = WebUIHandler::new();
383        let request = ServeRequest::new(RenderOptions::new("index.html", "/"), true, "");
384
385        let response = serve_request(&protocol, &handler, json!({}), &request)
386            .expect("partial response should succeed");
387
388        let json = response_json(response);
389
390        assert_eq!(
391            json["componentStyles"]["resources"]["my-page"],
392            json!({"kind": "style", "css": ".p{color:red}"})
393        );
394        assert!(
395            json["templates"].as_object().is_some_and(|a| a.len() == 1),
396            "Style strategy should still return templates"
397        );
398    }
399}